Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make trailingSlash a page/endpoint option, like prerender #7719

Merged
merged 10 commits into from
Nov 21, 2022
Merged
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/curly-feet-join.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/adapter-vercel': patch
---

Handle redirects inside SvelteKit
5 changes: 5 additions & 0 deletions .changeset/four-dots-compare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

[breaking] Make `trailingSlash` a page option, rather than configuration
15 changes: 15 additions & 0 deletions documentation/docs/20-core-concepts/40-page-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,18 @@ export const csr = false;
```

> If both `ssr` and `csr` are `false`, nothing will be rendered!

### trailingSlash

By default, SvelteKit will remove trailing slashes from URLs — if you visit `/about/`, it will respond with a redirect to `/about`. You can change this behaviour with the `trailingSlash` option, which can be one of `'never'` (the default), `'always'`, or `'ignore'`.

As with other page options, you can export this value from a `+layout.js` or a `+layout.server.js` and it will apply to all child pages. You can also export the configuration from `+server.js` files.

```js
/// file: src/routes/+layout.js
export const trailingSlash = 'always';
```

This option also affects [prerendering](#prerender). If `trailingSlash` is `always`, a route like `/about` will result in an `about/index.html` file, otherwise it will create `about.html`, mirroring static webserver conventions.

> 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.
2 changes: 1 addition & 1 deletion documentation/docs/20-core-concepts/50-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Most adapters will generate static HTML for any [prerenderable](/docs/page-optio

You can also use `adapter-static` to generate single-page apps (SPAs) by specifying a [fallback page and disabling SSR](https://github.com/sveltejs/kit/tree/master/packages/adapter-static#spa-mode).

> You must ensure [`trailingSlash`](/docs/configuration#trailingslash) is set appropriately for your environment. If your host does not render `/a.html` upon receiving a request for `/a` then you will need to set `trailingSlash: 'always'` to create `/a/index.html` instead.
> You must ensure [`trailingSlash`](/docs/page-options#trailingslash) is set appropriately for your environment. If your host does not render `/a.html` upon receiving a request for `/a` then you will need to set `trailingSlash: 'always'` to create `/a/index.html` instead.

#### Platform-specific context

Expand Down
2 changes: 1 addition & 1 deletion documentation/docs/40-best-practices/20-seo.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ Signals such as [Core Web Vitals](https://web.dev/vitals/#core-web-vitals) impac

#### Normalized URLs

SvelteKit redirects pathnames with trailing slashes to ones without (or vice versa depending on your [configuration](/docs/configuration#trailingslash)), as duplicate URLs are bad for SEO.
SvelteKit redirects pathnames with trailing slashes to ones without (or vice versa depending on your [configuration](/docs/page-options#trailingslash)), as duplicate URLs are bad for SEO.

### Manual setup

Expand Down
17 changes: 2 additions & 15 deletions documentation/docs/50-api-reference/10-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ const config = {
register: true,
files: (filepath) => !/\.DS_Store/.test(filepath)
},
trailingSlash: 'never',
version: {
name: Date.now().toString(),
pollInterval: 0
Expand Down Expand Up @@ -262,7 +261,7 @@ See [Prerendering](/docs/page-options#prerender). An object containing zero or m
- `'ignore'` - silently ignore the failure and continue
- `'warn'` — continue, but print a warning
- `(details) => void` — a custom error handler that takes a `details` object with `status`, `path`, `referrer`, `referenceType` and `message` properties. If you `throw` from this function, the build will fail

```js
/** @type {import('@sveltejs/kit').Config} */
const config = {
Expand All @@ -273,7 +272,7 @@ See [Prerendering](/docs/page-options#prerender). An object containing zero or m
if (path === '/not-found' && referrer === '/blog/how-we-built-our-404-page') {
return;
}

// otherwise fail the build
throw new Error(message);
}
Expand All @@ -298,18 +297,6 @@ An object containing zero or more of the following values:
- `register` - if set to `false`, will disable automatic service worker registration
- `files` - a function with the type of `(filepath: string) => boolean`. When `true`, the given file will be available in `$service-worker.files`, otherwise it will be excluded.

### trailingSlash

Whether to remove, append, or ignore trailing slashes when resolving URLs (note that this only applies to pages, not endpoints).

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

This option also affects [prerendering](/docs/page-options#prerender). If `trailingSlash` is `always`, a route like `/about` will result in an `about/index.html` file, otherwise it will create `about.html`, mirroring static webserver conventions.

> 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`](/docs/hooks#server-hooks-handle) function.

### version

An object containing zero or more of the following values:
Expand Down
2 changes: 1 addition & 1 deletion packages/adapter-static/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export default {
export const prerender = true;
```

> ⚠️ You must ensure SvelteKit's [`trailingSlash`](https://kit.svelte.dev/docs/configuration#trailingslash) option is set appropriately for your environment. If your host does not render `/a.html` upon receiving a request for `/a` then you will need to set `trailingSlash: 'always'` to create `/a/index.html` instead.
> ⚠️ You must ensure SvelteKit's [`trailingSlash`](https://kit.svelte.dev/docs/page-options#trailingslash) option is set appropriately for your environment. If your host does not render `/a.html` upon receiving a request for `/a` then you will need to set `trailingSlash: 'always'` to create `/a/index.html` instead.

## Zero-config support

Expand Down
78 changes: 0 additions & 78 deletions packages/adapter-vercel/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,83 +4,6 @@ import { fileURLToPath } from 'url';
import { nodeFileTrace } from '@vercel/nft';
import esbuild from 'esbuild';

// rules for clean URLs and trailing slash handling,
// generated with @vercel/routing-utils
const redirects = {
always: [
{
src: '^/(?:(.+)/)?index(?:\\.html)?/?$',
headers: {
Location: '/$1/'
},
status: 308
},
{
src: '^/(.*)\\.html/?$',
headers: {
Location: '/$1/'
},
status: 308
},
{
src: '^/\\.well-known(?:/.*)?$'
},
{
src: '^/((?:[^/]+/)*[^/\\.]+)$',
headers: {
Location: '/$1/'
},
status: 308
},
{
src: '^/((?:[^/]+/)*[^/]+\\.\\w+)/$',
headers: {
Location: '/$1'
},
status: 308
}
],
never: [
{
src: '^/(?:(.+)/)?index(?:\\.html)?/?$',
headers: {
Location: '/$1'
},
status: 308
},
{
src: '^/(.*)\\.html/?$',
headers: {
Location: '/$1'
},
status: 308
},
{
src: '^/(.*)/$',
headers: {
Location: '/$1'
},
status: 308
}
],
ignore: [
{
src: '^/(?:(.+)/)?index(?:\\.html)?/?$',
headers: {
Location: '/$1'
},
status: 308
},
{
src: '^/(.*)\\.html/?$',
headers: {
Location: '/$1'
},
status: 308
}
]
};

/** @type {import('.').default} **/
export default function ({ external = [], edge, split } = {}) {
return {
Expand Down Expand Up @@ -115,7 +38,6 @@ export default function ({ external = [], edge, split } = {}) {

/** @type {any[]} */
const routes = [
...redirects[builder.config.kit.trailingSlash],
...prerendered_redirects,
{
src: `/${builder.getAppPath()}/.+`,
Expand Down
2 changes: 1 addition & 1 deletion packages/kit/src/core/config/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ const get_defaults = (prefix = '') => ({
routes: undefined,
ssr: undefined,
target: undefined,
trailingSlash: 'never',
trailingSlash: undefined,
version: {
name: Date.now().toString(),
pollInterval: 0
Expand Down
15 changes: 9 additions & 6 deletions packages/kit/src/core/config/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ const options = object(
crawl: boolean(true),
createIndexFiles: error(
(keypath) =>
`${keypath} has been removed — it is now controlled by the trailingSlash option. See https://kit.svelte.dev/docs/configuration#trailingslash`
`${keypath} has been removed — it is now controlled by the trailingSlash option. See https://kit.svelte.dev/docs/page-options#trailingslash`
),
default: error(
(keypath) =>
Expand Down Expand Up @@ -320,7 +320,7 @@ const options = object(
// TODO remove for 1.0
router: error(
(keypath) =>
`${keypath} has been removed. You can set \`export const csr = false\` inside the top level +layout.js instead. See the PR for more information: https://github.com/sveltejs/kit/pull/6197`
`${keypath} has been removed. You can set \`export const csr = false\` inside the top level +layout.js (or +layout.server.js) instead. See the PR for more information: https://github.com/sveltejs/kit/pull/6197`
),

// TODO remove for 1.0
Expand All @@ -343,7 +343,10 @@ const options = object(
// TODO remove this for 1.0
target: error((keypath) => `${keypath} is no longer required, and should be removed`),

trailingSlash: list(['never', 'always', 'ignore']),
trailingSlash: error(
(keypath, input) =>
`${keypath} has been removed. You can set \`export const trailingSlash = '${input}'\` inside a top level +layout.js (or +layout.server.js) instead. See the PR for more information: https://github.com/sveltejs/kit/pull/7719`
),

version: object({
name: string(Date.now().toString()),
Expand Down Expand Up @@ -506,10 +509,10 @@ function assert_string(input, keypath) {
}
}

/** @param {(keypath?: string) => string} fn */
/** @param {(keypath?: string, input?: any) => string} fn */
function error(fn) {
return validate(undefined, (_, keypath) => {
throw new Error(fn(keypath));
return validate(undefined, (input, keypath) => {
throw new Error(fn(keypath, input));
});
}

Expand Down
1 change: 0 additions & 1 deletion packages/kit/src/exports/vite/build/build_server.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,6 @@ export class Server {
app_template,
app_template_contains_nonce: ${template.includes('%sveltekit.nonce%')},
error_template,
trailing_slash: ${s(config.kit.trailingSlash)},
version: ${s(config.kit.version.name)}
};
}
Expand Down
1 change: 0 additions & 1 deletion packages/kit/src/exports/vite/dev/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,6 @@ export async function dev(vite, vite_config, svelte_config) {
service_worker:
svelte_config.kit.serviceWorker.register &&
!!resolve_entry(svelte_config.kit.files.serviceWorker),
trailing_slash: svelte_config.kit.trailingSlash,
version: svelte_config.kit.version.name
},
{
Expand Down
24 changes: 15 additions & 9 deletions packages/kit/src/runtime/client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,10 @@ function check_for_removed_attributes() {
* @param {{
* target: Element;
* base: string;
* trailing_slash: import('types').TrailingSlash;
* }} opts
* @returns {import('./types').Client}
*/
export function create_client({ target, base, trailing_slash }) {
export function create_client({ target, base }) {
/** @type {Array<((url: URL) => boolean)>} */
const invalidated = [];

Expand Down Expand Up @@ -416,6 +415,14 @@ export function create_client({ target, base, trailing_slash }) {
}) {
const filtered = /** @type {import('./types').BranchNode[] } */ (branch.filter(Boolean));

/** @type {import('types').TrailingSlash} */
let slash = 'never';
for (const node of branch) {
if (node?.slash !== undefined) slash = node.slash;
}
url.pathname = normalize_path(url.pathname, slash);
url.search = url.search; // turn `/?` into `/`

/** @type {import('./types').NavigationFinished} */
const result = {
type: 'loaded',
Expand Down Expand Up @@ -657,7 +664,8 @@ export function create_client({ target, base, trailing_slash }) {
loader,
server: server_data_node,
shared: node.shared?.load ? { type: 'data', data, uses } : null,
data: data ?? server_data_node?.data ?? null
data: data ?? server_data_node?.data ?? null,
slash: node.shared?.trailingSlash ?? server_data_node?.slash
};
}

Expand Down Expand Up @@ -704,7 +712,8 @@ export function create_client({ target, base, trailing_slash }) {
parent: !!node.uses.parent,
route: !!node.uses.route,
url: !!node.uses.url
}
},
slash: node.slash
};
} else if (node?.type === 'skip') {
return previous ?? null;
Expand Down Expand Up @@ -999,12 +1008,9 @@ export function create_client({ target, base, trailing_slash }) {
const params = route.exec(path);

if (params) {
const normalized = new URL(
url.origin + normalize_path(url.pathname, trailing_slash) + url.search + url.hash
);
const id = normalized.pathname + normalized.search;
const id = url.pathname + url.search;
/** @type {import('./types').NavigationIntent} */
const intent = { id, invalidating, route, params: decode_params(params), url: normalized };
const intent = { id, invalidating, route, params: decode_params(params), url };
return intent;
}
}
Expand Down
6 changes: 2 additions & 4 deletions packages/kit/src/runtime/client/start.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,10 @@ import { set_version } from '../env.js';
* base: string;
* },
* target: Element;
* trailing_slash: import('types').TrailingSlash;
* version: string;
* }} opts
*/
export async function start({ env, hydrate, paths, target, trailing_slash, version }) {
export async function start({ env, hydrate, paths, target, version }) {
set_public_env(env);
set_paths(paths);
set_version(version);
Expand All @@ -30,8 +29,7 @@ export async function start({ env, hydrate, paths, target, trailing_slash, versi

const client = create_client({
target,
base: paths.base,
trailing_slash
base: paths.base
});

init({ client });
Expand Down
4 changes: 3 additions & 1 deletion packages/kit/src/runtime/client/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
prefetch,
prefetchRoutes
} from '$app/navigation';
import { CSRPageNode, CSRPageNodeLoader, CSRRoute, Uses } from 'types';
import { CSRPageNode, CSRPageNodeLoader, CSRRoute, TrailingSlash, Uses } from 'types';

export interface Client {
// public API, exposed via $app/navigation
Expand Down Expand Up @@ -67,12 +67,14 @@ export type BranchNode = {
server: DataNode | null;
shared: DataNode | null;
data: Record<string, any> | null;
slash?: TrailingSlash;
};

export interface DataNode {
type: 'data';
data: Record<string, any> | null;
uses: Uses;
slash?: TrailingSlash;
}

export interface NavigationState {
Expand Down
Loading