Skip to content
Closed
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"`
1 change: 1 addition & 0 deletions packages/astro/src/container/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,7 @@ export class experimental_AstroContainer {
ASTRO_CONFIG_DEFAULTS.base,
ASTRO_CONFIG_DEFAULTS.trailingSlash,
),
trailingSlash: ASTRO_CONFIG_DEFAULTS.trailingSlash,
prerender: false,
segments,
type,
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/core/app/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export function deserializeRouteData(rawRouteData: SerializedRouteData): RouteDa
component: rawRouteData.component,
pathname: rawRouteData.pathname || undefined,
segments: rawRouteData.segments,
trailingSlash: rawRouteData.trailingSlash ?? rawRouteData._meta.trailingSlash,
prerender: rawRouteData.prerender,
redirect: rawRouteData.redirect,
redirectRoute: rawRouteData.redirectRoute
Expand Down
5 changes: 4 additions & 1 deletion packages/astro/src/core/redirects/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ export function resolveRedirectTarget(
trailingSlash: 'always' | 'never' | 'ignore',
): string {
if (typeof redirectRoute !== 'undefined') {
const generate = getRouteGenerator(redirectRoute.segments, trailingSlash);
const generate = getRouteGenerator(
redirectRoute.segments,
redirectRoute.trailingSlash ?? trailingSlash,
);
return generate(params) || redirectRoute?.pathname || '/';
} else if (typeof redirect === 'string') {
if (redirectIsExternal(redirect)) {
Expand Down
5 changes: 4 additions & 1 deletion packages/astro/src/core/render/paginate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ export function generatePaginateFunction(
data: readonly any[],
args: PaginateOptions<Props, Params> = {},
): ReturnType<PaginateFunction> {
const generate = getRouteGenerator(routeMatch.segments, trailingSlash);
const generate = getRouteGenerator(
routeMatch.segments,
routeMatch.trailingSlash ?? trailingSlash,
);
let { pageSize: _pageSize, params: _params, props: _props } = args;
const pageSize = _pageSize || 10;
const paramName = 'page';
Expand Down
6 changes: 6 additions & 0 deletions packages/astro/src/core/routing/create-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ function createFileBasedRoutes(
isIndex: item.isIndex,
type: item.isPage ? 'page' : 'endpoint',
pattern,
trailingSlash,
segments,
params,
component,
Expand Down Expand Up @@ -400,6 +401,7 @@ function createRoutesFromEntriesByDir(
isIndex: item.isIndex,
type: item.isPage ? 'page' : 'endpoint',
pattern,
trailingSlash,
segments,
params,
component,
Expand Down Expand Up @@ -485,6 +487,7 @@ function createInjectedRoutes({ settings, cwd }: CreateRouteManifestParams): Rou
isIndex: false,
route,
pattern,
trailingSlash,
segments,
params,
component,
Expand Down Expand Up @@ -587,6 +590,7 @@ function createRedirectRoutes(
isIndex: false,
route,
pattern,
trailingSlash,
segments,
params,
component: from,
Expand Down Expand Up @@ -896,6 +900,7 @@ export function createI18nFallbackRoutes(
route,
segments,
pattern: getPattern(segments, config.base, config.trailingSlash),
trailingSlash: config.trailingSlash,
type: 'fallback',
});
}
Expand Down Expand Up @@ -970,6 +975,7 @@ export function createI18nFallbackRoutes(
route,
segments,
pattern: getPattern(segments, config.base, config.trailingSlash),
trailingSlash: config.trailingSlash,
type: 'fallback',
fallbackRoutes: [],
};
Expand Down
7 changes: 6 additions & 1 deletion packages/astro/src/core/routing/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ export function stringifyParams(
route: RouteData,
trailingSlash: AstroConfig['trailingSlash'],
) {
// RouteData.trailingSlash is computed alongside route.pattern in create-manifest.ts.
// File endpoints with extensions (e.g. [name].json.ts) use 'never' so generated
// paths match the route pattern even when config.trailingSlash is 'always'.
const routeTrailingSlash = route.trailingSlash ?? trailingSlash;

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

return getRouteGenerator(route.segments, trailingSlash)(validatedParams);
return getRouteGenerator(route.segments, routeTrailingSlash)(validatedParams);
}
1 change: 1 addition & 0 deletions packages/astro/src/core/routing/parse-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export function parseRoute(
params,
pathname: pathname || undefined,
pattern: getPattern(segments, options.config.base, options.config.trailingSlash),
trailingSlash: options.config.trailingSlash,
segments,
type: parseOptions.type ?? 'page',
prerender: parseOptions.prerender ?? false,
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/core/server-islands/endpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ function getServerIslandRouteData(config: ConfigFields) {
params: ['name'],
segments,
pattern: getPattern(segments, config.base, config.trailingSlash),
trailingSlash: config.trailingSlash,
prerender: false,
isIndex: false,
fallbackRoutes: [],
Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/integrations/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -694,7 +694,7 @@ export function toIntegrationResolvedRoute(
pattern: route.route,
params: route.params,
origin: route.origin,
generate: getRouteGenerator(route.segments, trailingSlash),
generate: getRouteGenerator(route.segments, route.trailingSlash ?? trailingSlash),
patternRegex: route.pattern,
segments: route.segments,
type: route.type,
Expand Down
4 changes: 4 additions & 0 deletions packages/astro/src/types/public/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ export interface RouteData {
* ```
*/
pattern: RegExp;
/**
* The trailing slash behavior used to generate and match this route.
*/
trailingSlash?: AstroConfig['trailingSlash'];
/**
* Similar to the "params" field, but with more associated metadata. For example, for `/site/[blog]/[...slug].astro`, the segments are:
*
Expand Down
33 changes: 27 additions & 6 deletions packages/astro/test/units/integrations/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,25 +173,46 @@ describe('toIntegrationResolvedRoute', () => {
assert.equal(result.fallbackRoutes[0].origin, 'internal');
});

it('applies trailingSlash "always" to generate function', () => {
const route = createRouteData({ route: '/about' });
const result = toIntegrationResolvedRoute(route, 'always');
it('applies route trailingSlash "always" to generate function', () => {
const route = createRouteData({ route: '/about', trailingSlash: 'always' });
const result = toIntegrationResolvedRoute(route, 'never');
assert.equal(result.generate({}), '/about/');
});

it('applies trailingSlash "never" to generate function', () => {
const route = createRouteData({ route: '/about' });
const result = toIntegrationResolvedRoute(route, 'never');
it('applies route trailingSlash "never" to generate function', () => {
const route = createRouteData({ route: '/about', trailingSlash: 'never' });
const result = toIntegrationResolvedRoute(route, 'always');
const generated = result.generate({});
assert.ok(!generated.endsWith('/') || generated === '/');
});

it('falls back to provided trailingSlash when route metadata is missing', () => {
const route = createRouteData({ route: '/about' });
delete route.trailingSlash;
const result = toIntegrationResolvedRoute(route, 'always');

assert.equal(result.generate({}), '/about/');
});

it('handles endpoint route type', () => {
const route = createRouteData({ route: '/api/data', type: 'endpoint' });
const result = toIntegrationResolvedRoute(route, 'ignore');
assert.equal(result.type, 'endpoint');
});

it('uses route-specific trailingSlash in generate function', () => {
const route = makeRoute({
route: '/api/[name].json',
segments: [[staticPart('api')], [dynamicPart('name'), staticPart('.json')]],
trailingSlash: 'never',
pathname: undefined,
type: 'endpoint',
});
const result = toIntegrationResolvedRoute(route, 'always');

assert.equal(result.generate({ name: 'foo' }), '/api/foo.json');
});

it('handles spread params in generate', () => {
const route = makeRoute({
route: '/blog/[...slug]',
Expand Down
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 @@ -186,3 +188,44 @@ describe('routing - generator', () => {
assert.equal(generator({ foo: 0 }), '/0');
});
});

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');
});
});
3 changes: 3 additions & 0 deletions packages/astro/test/units/routing/manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,7 @@ describe('routing - createRoutesList', () => {
// Endpoint with file extension should still force 'never'
const endpointRoute = ignoreManifest.routes.find((r) => r.route === '/feed.xml');
assert.ok(endpointRoute, 'endpoint route should exist');
assert.equal(endpointRoute.trailingSlash, 'never');
assert.equal(
endpointRoute.pattern.test('/feed.xml'),
true,
Expand All @@ -505,6 +506,7 @@ describe('routing - createRoutesList', () => {
);
const alwaysPageRoute = alwaysManifest.routes.find((r) => r.route === '/hello.world');
assert.ok(alwaysPageRoute, 'page route should exist with trailingSlash always');
assert.equal(alwaysPageRoute.trailingSlash, 'always');
assert.equal(
alwaysPageRoute.pattern.test('/hello.world/'),
true,
Expand Down Expand Up @@ -535,6 +537,7 @@ describe('routing - createRoutesList', () => {
);
const route = manifest.routes.find((r) => r.route === '/api/[name].json');
assert.ok(route, 'dynamic file endpoint route should exist');
assert.equal(route.trailingSlash, 'never');
assert.equal(route.pattern.test('/api/bar.json'), true, 'should match without trailing slash');
assert.equal(
route.pattern.test('/api/bar.json/'),
Expand Down
1 change: 1 addition & 0 deletions packages/astro/test/units/routing/test-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const makeRoute = ({
params: routeParams,
pathname,
pattern: getPattern(segments, '/', trailingSlash),
trailingSlash,
segments,
type,
prerender,
Expand Down
Loading