diff --git a/.changeset/quiet-cherries-smile.md b/.changeset/quiet-cherries-smile.md new file mode 100644 index 000000000000..aca49295f943 --- /dev/null +++ b/.changeset/quiet-cherries-smile.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +Add trailingSlash: 'never' | 'always' | 'ignore' option diff --git a/documentation/docs/13-configuration.md b/documentation/docs/13-configuration.md index e45edbeec77a..87448b3a9142 100644 --- a/documentation/docs/13-configuration.md +++ b/documentation/docs/13-configuration.md @@ -42,6 +42,7 @@ const config = { router: true, ssr: true, target: null, + trailingSlash: 'never', vite: () => ({}) }, @@ -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. + ### 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. diff --git a/packages/kit/src/core/build/index.js b/packages/kit/src/core/build/index.js index 25569250ae6d..3137728009e7 100644 --- a/packages/kit/src/core/build/index.js +++ b/packages/kit/src/core/build/index.js @@ -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)} }; } diff --git a/packages/kit/src/core/dev/index.js b/packages/kit/src/core/dev/index.js index bbf0c73a1f06..3ec12c5c013b 100644 --- a/packages/kit/src/core/dev/index.js +++ b/packages/kit/src/core/dev/index.js @@ -291,7 +291,8 @@ class Watcher extends EventEmitter { } return rendered; - } + }, + trailing_slash: this.config.kit.trailingSlash } ); diff --git a/packages/kit/src/core/load_config/index.spec.js b/packages/kit/src/core/load_config/index.spec.js index 9eb90612b02b..4678a7300126 100644 --- a/packages/kit/src/core/load_config/index.spec.js +++ b/packages/kit/src/core/load_config/index.spec.js @@ -39,7 +39,8 @@ test('fills in defaults', () => { }, router: true, ssr: true, - target: null + target: null, + trailingSlash: 'never' }, preprocess: null }); @@ -122,7 +123,8 @@ test('fills in partial blanks', () => { }, router: true, ssr: true, - target: null + target: null, + trailingSlash: 'never' }, preprocess: null }); diff --git a/packages/kit/src/core/load_config/options.js b/packages/kit/src/core/load_config/options.js index 3ad80d454859..8c63e9748885 100644 --- a/packages/kit/src/core/load_config/options.js +++ b/packages/kit/src/core/load_config/options.js @@ -122,6 +122,8 @@ const options = { target: expect_string(null), + trailingSlash: expect_enum(['never', 'always', 'ignore']), + vite: { type: 'leaf', default: () => ({}), @@ -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 diff --git a/packages/kit/src/core/load_config/test/index.js b/packages/kit/src/core/load_config/test/index.js index f67ea5561c2d..c86f90e55307 100644 --- a/packages/kit/src/core/load_config/test/index.js +++ b/packages/kit/src/core/load_config/test/index.js @@ -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 }); diff --git a/packages/kit/src/runtime/client/router.js b/packages/kit/src/runtime/client/router.js index 48f802d5dfcb..eda914e549f6 100644 --- a/packages/kit/src/runtime/client/router.js +++ b/packages/kit/src/runtime/client/router.js @@ -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 */ @@ -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(); diff --git a/packages/kit/src/runtime/client/start.js b/packages/kit/src/runtime/client/start.js index 98217a7b4bfe..f835c84cb90d 100644 --- a/packages/kit/src/runtime/client/start.js +++ b/packages/kit/src/runtime/client/start.js @@ -17,6 +17,7 @@ import { set_paths } from '../paths.js'; * host: string; * route: boolean; * spa: boolean; + * trailing_slash: import('types/internal').TrailingSlash; * hydrate: { * status: number; * error: Error; @@ -24,7 +25,7 @@ import { set_paths } from '../paths.js'; * 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'); } @@ -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({ diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index 56c6b595f104..4acc8179dbbc 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -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 { diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index b77b38f83369..e6538758ba6c 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -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)}, diff --git a/packages/kit/test/apps/options/source/pages/headers/_tests.js b/packages/kit/test/apps/options/source/pages/headers/_tests.js index 65253e7534cf..83254c1bec5e 100644 --- a/packages/kit/test/apps/options/source/pages/headers/_tests.js +++ b/packages/kit/test/apps/options/source/pages/headers/_tests.js @@ -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); }); diff --git a/packages/kit/test/apps/options/source/pages/host/_tests.js b/packages/kit/test/apps/options/source/pages/host/_tests.js index b86405c26506..ba8ae9538827 100644 --- a/packages/kit/test/apps/options/source/pages/host/_tests.js +++ b/packages/kit/test/apps/options/source/pages/host/_tests.js @@ -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'); diff --git a/packages/kit/test/apps/options/source/pages/slash/_tests.js b/packages/kit/test/apps/options/source/pages/slash/_tests.js new file mode 100644 index 000000000000..3e19d9fa02f4 --- /dev/null +++ b/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/'); + }); +} diff --git a/packages/kit/test/apps/options/source/pages/slash/child.svelte b/packages/kit/test/apps/options/source/pages/slash/child.svelte new file mode 100644 index 000000000000..2529728f93db --- /dev/null +++ b/packages/kit/test/apps/options/source/pages/slash/child.svelte @@ -0,0 +1,5 @@ + + +

{$page.path}

diff --git a/packages/kit/test/apps/options/source/pages/slash/index.svelte b/packages/kit/test/apps/options/source/pages/slash/index.svelte new file mode 100644 index 000000000000..19654d3237fd --- /dev/null +++ b/packages/kit/test/apps/options/source/pages/slash/index.svelte @@ -0,0 +1,7 @@ + + +

{$page.path}

+ +/slash/child diff --git a/packages/kit/test/apps/options/svelte.config.js b/packages/kit/test/apps/options/svelte.config.js index f3e343724df6..3d191cff3de9 100644 --- a/packages/kit/test/apps/options/svelte.config.js +++ b/packages/kit/test/apps/options/svelte.config.js @@ -11,6 +11,7 @@ const config = { floc: true, target: '#content-goes-here', host: 'example.com', + trailingSlash: 'always', vite: { build: { minify: false diff --git a/packages/kit/types/config.d.ts b/packages/kit/types/config.d.ts index 662be69770e5..cd3d7e54ade4 100644 --- a/packages/kit/types/config.d.ts +++ b/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 = { @@ -57,6 +57,7 @@ export type Config = { router?: boolean; ssr?: boolean; target?: string; + trailingSlash?: TrailingSlash; vite?: ViteConfig | (() => ViteConfig); }; preprocess?: any; @@ -95,6 +96,7 @@ export type ValidatedConfig = { router: boolean; ssr: boolean; target: string; + trailingSlash: TrailingSlash; vite: () => ViteConfig; }; preprocess: any; diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts index cda1d23a66f1..6e8189f1c9b7 100644 --- a/packages/kit/types/internal.d.ts +++ b/packages/kit/types/internal.d.ts @@ -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 = { @@ -207,3 +208,5 @@ export type NormalizedLoadOutput = { context?: Record; maxage?: number; }; + +export type TrailingSlash = 'never' | 'always' | 'ignore';