Skip to content

Commit 9a2cc0a

Browse files
author
Rich Harris
authored
Add trailingSlash option (#1404)
* failing test for #733 * implement trailingSlash option - closes #733 * changeset * docs for trailingSlash option
1 parent cfd6c3c commit 9a2cc0a

File tree

19 files changed

+126
-24
lines changed

19 files changed

+126
-24
lines changed

.changeset/quiet-cherries-smile.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': patch
3+
---
4+
5+
Add trailingSlash: 'never' | 'always' | 'ignore' option

documentation/docs/13-configuration.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const config = {
4242
router: true,
4343
ssr: true,
4444
target: null,
45+
trailingSlash: 'never',
4546
vite: () => ({})
4647
},
4748

@@ -138,6 +139,16 @@ Enables or disables [server-side rendering](#ssr-and-javascript-ssr) app-wide.
138139

139140
Specifies an element to mount the app to. It must be a DOM selector that identifies an element that exists in your template file. If unspecified, the app will be mounted to `document.body`.
140141

142+
### trailingSlash
143+
144+
Whether to remove, append, or ignore trailing slashes when resolving URLs to routes.
145+
146+
- `"never"` — redirect `/x/` to `/x`
147+
- `"always"` — redirect `/x` to `/x/`
148+
- `"ignore"` — don't automatically add or remove trailing slashes. `/x` and `/x/` will be treated equivalently
149+
150+
> Ignoring trailing slashes is not recommended — the semantics of relative paths differ between the two cases (`./y` from `/x` is `/y`, but from `/x/` is `/x/y`), and `/x` and `/x/` are treated as separate URLs which is harmful to SEO. If you use this option, ensure that you implement logic for conditionally adding or removing trailing slashes from `request.path` inside your [`handle`](#hooks-handle) function.
151+
141152
### vite
142153

143154
A [Vite config object](https://vitejs.dev/config), or a function that returns one. Not all configuration options can be set, since SvelteKit depends on certain values being configured internally.

packages/kit/src/core/build/index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,8 @@ async function build_server(
318318
router: ${s(config.kit.router)},
319319
ssr: ${s(config.kit.ssr)},
320320
target: ${s(config.kit.target)},
321-
template
321+
template,
322+
trailing_slash: ${s(config.kit.trailingSlash)}
322323
};
323324
}
324325

packages/kit/src/core/dev/index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,8 @@ class Watcher extends EventEmitter {
291291
}
292292

293293
return rendered;
294-
}
294+
},
295+
trailing_slash: this.config.kit.trailingSlash
295296
}
296297
);
297298

packages/kit/src/core/load_config/index.spec.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ test('fills in defaults', () => {
3939
},
4040
router: true,
4141
ssr: true,
42-
target: null
42+
target: null,
43+
trailingSlash: 'never'
4344
},
4445
preprocess: null
4546
});
@@ -122,7 +123,8 @@ test('fills in partial blanks', () => {
122123
},
123124
router: true,
124125
ssr: true,
125-
target: null
126+
target: null,
127+
trailingSlash: 'never'
126128
},
127129
preprocess: null
128130
});

packages/kit/src/core/load_config/options.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ const options = {
122122

123123
target: expect_string(null),
124124

125+
trailingSlash: expect_enum(['never', 'always', 'ignore']),
126+
125127
vite: {
126128
type: 'leaf',
127129
default: () => ({}),
@@ -186,6 +188,28 @@ function expect_boolean(boolean) {
186188
};
187189
}
188190

191+
/**
192+
* @param {string[]} options
193+
* @returns {ConfigDefinition}
194+
*/
195+
function expect_enum(options, def = options[0]) {
196+
return {
197+
type: 'leaf',
198+
default: def,
199+
validate: (option, keypath) => {
200+
if (!options.includes(option)) {
201+
// prettier-ignore
202+
const msg = options.length > 2
203+
? `${keypath} should be one of ${options.slice(0, -1).map(option => `"${option}"`).join(', ')} or "${options[options.length - 1]}"`
204+
: `${keypath} should be either "${options[0]}" or "${options[1]}"`;
205+
206+
throw new Error(msg);
207+
}
208+
return option;
209+
}
210+
};
211+
}
212+
189213
/**
190214
* @param {any} option
191215
* @param {string} keypath

packages/kit/src/core/load_config/test/index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ async function testLoadDefaultConfig(path) {
4141
prerender: { crawl: true, enabled: true, force: false, pages: ['*'] },
4242
router: true,
4343
ssr: true,
44-
target: null
44+
target: null,
45+
trailingSlash: 'never'
4546
},
4647
preprocess: null
4748
});

packages/kit/src/runtime/client/router.js

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@ export class Router {
2020
/** @param {{
2121
* base: string;
2222
* routes: import('types/internal').CSRRoute[];
23+
* trailing_slash: import('types/internal').TrailingSlash;
2324
* }} opts */
24-
constructor({ base, routes }) {
25+
constructor({ base, routes, trailing_slash }) {
2526
this.base = base;
2627
this.routes = routes;
28+
this.trailing_slash = trailing_slash;
2729
}
2830

2931
/** @param {import('./renderer').Renderer} renderer */
@@ -215,16 +217,27 @@ export class Router {
215217
async _navigate(url, scroll, chain, hash) {
216218
const info = this.parse(url);
217219

220+
// remove trailing slashes
221+
if (info.path !== '/') {
222+
const has_trailing_slash = info.path.endsWith('/');
223+
224+
const incorrect =
225+
(has_trailing_slash && this.trailing_slash === 'never') ||
226+
(!has_trailing_slash &&
227+
this.trailing_slash === 'always' &&
228+
!info.path.split('/').pop().includes('.'));
229+
230+
if (incorrect) {
231+
info.path = has_trailing_slash ? info.path.slice(0, -1) : info.path + '/';
232+
history.replaceState({}, '', `${info.path}${location.search}`);
233+
}
234+
}
235+
218236
this.renderer.notify({
219237
path: info.path,
220238
query: info.query
221239
});
222240

223-
// remove trailing slashes
224-
if (location.pathname.endsWith('/') && location.pathname !== '/') {
225-
history.replaceState({}, '', `${location.pathname.slice(0, -1)}${location.search}`);
226-
}
227-
228241
await this.renderer.update(info, chain, false);
229242

230243
document.body.focus();

packages/kit/src/runtime/client/start.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,15 @@ import { set_paths } from '../paths.js';
1717
* host: string;
1818
* route: boolean;
1919
* spa: boolean;
20+
* trailing_slash: import('types/internal').TrailingSlash;
2021
* hydrate: {
2122
* status: number;
2223
* error: Error;
2324
* nodes: Array<Promise<import('types/internal').CSRComponent>>;
2425
* page: import('types/page').Page;
2526
* };
2627
* }} opts */
27-
export async function start({ paths, target, session, host, route, spa, hydrate }) {
28+
export async function start({ paths, target, session, host, route, spa, trailing_slash, hydrate }) {
2829
if (import.meta.env.DEV && !target) {
2930
throw new Error('Missing target element. See https://kit.svelte.dev/docs#configuration-target');
3031
}
@@ -33,7 +34,8 @@ export async function start({ paths, target, session, host, route, spa, hydrate
3334
route &&
3435
new Router({
3536
base: paths.base,
36-
routes
37+
routes,
38+
trailing_slash
3739
});
3840

3941
const renderer = new Renderer({

packages/kit/src/runtime/server/index.js

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,25 @@ import { hash } from '../hash.js';
1111
* @param {import('types/internal').SSRRenderState} [state]
1212
*/
1313
export async function respond(incoming, options, state = {}) {
14-
if (incoming.path.endsWith('/') && incoming.path !== '/') {
15-
const q = incoming.query.toString();
14+
if (incoming.path !== '/' && options.trailing_slash !== 'ignore') {
15+
const has_trailing_slash = incoming.path.endsWith('/');
1616

17-
return {
18-
status: 301,
19-
headers: {
20-
location: encodeURI(incoming.path.slice(0, -1) + (q ? `?${q}` : ''))
21-
}
22-
};
17+
if (
18+
(has_trailing_slash && options.trailing_slash === 'never') ||
19+
(!has_trailing_slash &&
20+
options.trailing_slash === 'always' &&
21+
!incoming.path.split('/').pop().includes('.'))
22+
) {
23+
const path = has_trailing_slash ? incoming.path.slice(0, -1) : incoming.path + '/';
24+
const q = incoming.query.toString();
25+
26+
return {
27+
status: 301,
28+
headers: {
29+
location: encodeURI(path + (q ? `?${q}` : ''))
30+
}
31+
};
32+
}
2333
}
2434

2535
try {

0 commit comments

Comments
 (0)