Skip to content

Commit

Permalink
Add trailingSlash option (#1404)
Browse files Browse the repository at this point in the history
* failing test for #733

* implement trailingSlash option - closes #733

* changeset

* docs for trailingSlash option
  • Loading branch information
Rich Harris committed May 11, 2021
1 parent cfd6c3c commit 9a2cc0a
Show file tree
Hide file tree
Showing 19 changed files with 126 additions and 24 deletions.
5 changes: 5 additions & 0 deletions .changeset/quiet-cherries-smile.md
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

Add trailingSlash: 'never' | 'always' | 'ignore' option
11 changes: 11 additions & 0 deletions documentation/docs/13-configuration.md
Expand Up @@ -42,6 +42,7 @@ const config = {
router: true,
ssr: true,
target: null,
trailingSlash: 'never',
vite: () => ({})
},

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

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`.

### trailingSlash

Whether to remove, append, or ignore trailing slashes when resolving URLs to routes.

- `"never"` — redirect `/x/` to `/x`
- `"always"` — redirect `/x` to `/x/`
- `"ignore"` — don't automatically add or remove trailing slashes. `/x` and `/x/` will be treated equivalently

> 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.

This comment has been minimized.

Copy link
@Valexr

Valexr May 12, 2021

[handle](docs#hooks-handle) ?

### vite

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.
3 changes: 2 additions & 1 deletion packages/kit/src/core/build/index.js
Expand Up @@ -318,7 +318,8 @@ async function build_server(
router: ${s(config.kit.router)},
ssr: ${s(config.kit.ssr)},
target: ${s(config.kit.target)},
template
template,
trailing_slash: ${s(config.kit.trailingSlash)}
};
}
Expand Down
3 changes: 2 additions & 1 deletion packages/kit/src/core/dev/index.js
Expand Up @@ -291,7 +291,8 @@ class Watcher extends EventEmitter {
}

return rendered;
}
},
trailing_slash: this.config.kit.trailingSlash
}
);

Expand Down
6 changes: 4 additions & 2 deletions packages/kit/src/core/load_config/index.spec.js
Expand Up @@ -39,7 +39,8 @@ test('fills in defaults', () => {
},
router: true,
ssr: true,
target: null
target: null,
trailingSlash: 'never'
},
preprocess: null
});
Expand Down Expand Up @@ -122,7 +123,8 @@ test('fills in partial blanks', () => {
},
router: true,
ssr: true,
target: null
target: null,
trailingSlash: 'never'
},
preprocess: null
});
Expand Down
24 changes: 24 additions & 0 deletions packages/kit/src/core/load_config/options.js
Expand Up @@ -122,6 +122,8 @@ const options = {

target: expect_string(null),

trailingSlash: expect_enum(['never', 'always', 'ignore']),

vite: {
type: 'leaf',
default: () => ({}),
Expand Down Expand Up @@ -186,6 +188,28 @@ function expect_boolean(boolean) {
};
}

/**
* @param {string[]} options
* @returns {ConfigDefinition}
*/
function expect_enum(options, def = options[0]) {
return {
type: 'leaf',
default: def,
validate: (option, keypath) => {
if (!options.includes(option)) {
// prettier-ignore
const msg = options.length > 2
? `${keypath} should be one of ${options.slice(0, -1).map(option => `"${option}"`).join(', ')} or "${options[options.length - 1]}"`
: `${keypath} should be either "${options[0]}" or "${options[1]}"`;

throw new Error(msg);
}
return option;
}
};
}

/**
* @param {any} option
* @param {string} keypath
Expand Down
3 changes: 2 additions & 1 deletion packages/kit/src/core/load_config/test/index.js
Expand Up @@ -41,7 +41,8 @@ async function testLoadDefaultConfig(path) {
prerender: { crawl: true, enabled: true, force: false, pages: ['*'] },
router: true,
ssr: true,
target: null
target: null,
trailingSlash: 'never'
},
preprocess: null
});
Expand Down
25 changes: 19 additions & 6 deletions packages/kit/src/runtime/client/router.js
Expand Up @@ -20,10 +20,12 @@ export class Router {
/** @param {{
* base: string;
* routes: import('types/internal').CSRRoute[];
* trailing_slash: import('types/internal').TrailingSlash;
* }} opts */
constructor({ base, routes }) {
constructor({ base, routes, trailing_slash }) {
this.base = base;
this.routes = routes;
this.trailing_slash = trailing_slash;
}

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

// remove trailing slashes
if (info.path !== '/') {
const has_trailing_slash = info.path.endsWith('/');

const incorrect =
(has_trailing_slash && this.trailing_slash === 'never') ||
(!has_trailing_slash &&
this.trailing_slash === 'always' &&
!info.path.split('/').pop().includes('.'));

if (incorrect) {
info.path = has_trailing_slash ? info.path.slice(0, -1) : info.path + '/';
history.replaceState({}, '', `${info.path}${location.search}`);
}
}

this.renderer.notify({
path: info.path,
query: info.query
});

// remove trailing slashes
if (location.pathname.endsWith('/') && location.pathname !== '/') {
history.replaceState({}, '', `${location.pathname.slice(0, -1)}${location.search}`);
}

await this.renderer.update(info, chain, false);

document.body.focus();
Expand Down
6 changes: 4 additions & 2 deletions packages/kit/src/runtime/client/start.js
Expand Up @@ -17,14 +17,15 @@ import { set_paths } from '../paths.js';
* host: string;
* route: boolean;
* spa: boolean;
* trailing_slash: import('types/internal').TrailingSlash;
* hydrate: {
* status: number;
* error: Error;
* nodes: Array<Promise<import('types/internal').CSRComponent>>;
* page: import('types/page').Page;
* };
* }} opts */
export async function start({ paths, target, session, host, route, spa, hydrate }) {
export async function start({ paths, target, session, host, route, spa, trailing_slash, hydrate }) {
if (import.meta.env.DEV && !target) {
throw new Error('Missing target element. See https://kit.svelte.dev/docs#configuration-target');
}
Expand All @@ -33,7 +34,8 @@ export async function start({ paths, target, session, host, route, spa, hydrate
route &&
new Router({
base: paths.base,
routes
routes,
trailing_slash
});

const renderer = new Renderer({
Expand Down
26 changes: 18 additions & 8 deletions packages/kit/src/runtime/server/index.js
Expand Up @@ -11,15 +11,25 @@ import { hash } from '../hash.js';
* @param {import('types/internal').SSRRenderState} [state]
*/
export async function respond(incoming, options, state = {}) {
if (incoming.path.endsWith('/') && incoming.path !== '/') {
const q = incoming.query.toString();
if (incoming.path !== '/' && options.trailing_slash !== 'ignore') {
const has_trailing_slash = incoming.path.endsWith('/');

return {
status: 301,
headers: {
location: encodeURI(incoming.path.slice(0, -1) + (q ? `?${q}` : ''))
}
};
if (
(has_trailing_slash && options.trailing_slash === 'never') ||
(!has_trailing_slash &&
options.trailing_slash === 'always' &&
!incoming.path.split('/').pop().includes('.'))
) {
const path = has_trailing_slash ? incoming.path.slice(0, -1) : incoming.path + '/';
const q = incoming.query.toString();

return {
status: 301,
headers: {
location: encodeURI(path + (q ? `?${q}` : ''))
}
};
}
}

try {
Expand Down
1 change: 1 addition & 0 deletions packages/kit/src/runtime/server/page/render.js
Expand Up @@ -124,6 +124,7 @@ export async function render_response({
host: ${page && page.host ? s(page.host) : 'location.host'},
route: ${!!page_config.router},
spa: ${!page_config.ssr},
trailing_slash: ${s(options.trailing_slash)},
hydrate: ${page_config.ssr && page_config.hydrate? `{
status: ${status},
error: ${serialize_error(error)},
Expand Down
Expand Up @@ -2,7 +2,7 @@ import * as assert from 'uvu/assert';

/** @type {import('test').TestMaker} */
export default function (test) {
test('enables floc', '/headers', async ({ response }) => {
test('enables floc', '/headers/', async ({ response }) => {
const headers = response.headers();
assert.equal(headers['permissions-policy'], undefined);
});
Expand Down
2 changes: 1 addition & 1 deletion packages/kit/test/apps/options/source/pages/host/_tests.js
Expand Up @@ -2,7 +2,7 @@ import * as assert from 'uvu/assert';

/** @type {import('test').TestMaker} */
export default function (test) {
test('sets host', '/host', async ({ page }) => {
test('sets host', '/host/', async ({ page }) => {
assert.equal(await page.textContent('[data-source="load"]'), 'example.com');
assert.equal(await page.textContent('[data-source="store"]'), 'example.com');
assert.equal(await page.textContent('[data-source="endpoint"]'), 'example.com');
Expand Down
13 changes: 13 additions & 0 deletions packages/kit/test/apps/options/source/pages/slash/_tests.js
@@ -0,0 +1,13 @@
import * as assert from 'uvu/assert';

/** @type {import('test').TestMaker} */
export default function (test) {
test('adds trailing slash', '/slash', async ({ base, page, clicknav }) => {
assert.equal(page.url(), `${base}/slash/`);
assert.equal(await page.textContent('h2'), '/slash/');

await clicknav('[href="/slash/child"]');
assert.equal(page.url(), `${base}/slash/child/`);
assert.equal(await page.textContent('h2'), '/slash/child/');
});
}
@@ -0,0 +1,5 @@
<script>
import { page } from '$app/stores';
</script>

<h2>{$page.path}</h2>
@@ -0,0 +1,7 @@
<script>
import { page } from '$app/stores';
</script>

<h2>{$page.path}</h2>

<a href="/slash/child">/slash/child</a>
1 change: 1 addition & 0 deletions packages/kit/test/apps/options/svelte.config.js
Expand Up @@ -11,6 +11,7 @@ const config = {
floc: true,
target: '#content-goes-here',
host: 'example.com',
trailingSlash: 'always',
vite: {
build: {
minify: false
Expand Down
4 changes: 3 additions & 1 deletion packages/kit/types/config.d.ts
@@ -1,4 +1,4 @@
import { Logger } from './internal';
import { Logger, TrailingSlash } from './internal';
import { UserConfig as ViteConfig } from 'vite';

export type AdapterUtils = {
Expand Down Expand Up @@ -57,6 +57,7 @@ export type Config = {
router?: boolean;
ssr?: boolean;
target?: string;
trailingSlash?: TrailingSlash;
vite?: ViteConfig | (() => ViteConfig);
};
preprocess?: any;
Expand Down Expand Up @@ -95,6 +96,7 @@ export type ValidatedConfig = {
router: boolean;
ssr: boolean;
target: string;
trailingSlash: TrailingSlash;
vite: () => ViteConfig;
};
preprocess: any;
Expand Down
3 changes: 3 additions & 0 deletions packages/kit/types/internal.d.ts
Expand Up @@ -146,6 +146,7 @@ export type SSRRenderOptions = {
ssr: boolean;
target: string;
template: ({ head, body }: { head: string; body: string }) => string;
trailing_slash: TrailingSlash;
};

export type SSRRenderState = {
Expand Down Expand Up @@ -207,3 +208,5 @@ export type NormalizedLoadOutput = {
context?: Record<string, any>;
maxage?: number;
};

export type TrailingSlash = 'never' | 'always' | 'ignore';

0 comments on commit 9a2cc0a

Please sign in to comment.