From 12d7b45e1807139cd17dd2fcaec8bacc2668ee7d Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 4 Mar 2025 15:08:18 +0100 Subject: [PATCH] feat: provide `normalizeUrl` helper Provides people a way to normalize a raw URL that could contain SvelteKit-internal data. One use case would be that you want to use middleware in front of, but outside of SvelteKit Extracted from #13477 --- .changeset/tough-mangos-develop.md | 5 +++ packages/kit/src/exports/index.js | 54 ++++++++++++++++++++++++++ packages/kit/src/exports/index.spec.js | 53 +++++++++++++++++++++++++ packages/kit/types/index.d.ts | 17 ++++++++ 4 files changed, 129 insertions(+) create mode 100644 .changeset/tough-mangos-develop.md create mode 100644 packages/kit/src/exports/index.spec.js diff --git a/.changeset/tough-mangos-develop.md b/.changeset/tough-mangos-develop.md new file mode 100644 index 000000000000..0b560a7b429b --- /dev/null +++ b/.changeset/tough-mangos-develop.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: provide `normalizeUrl` helper diff --git a/packages/kit/src/exports/index.js b/packages/kit/src/exports/index.js index 3b69f24de40e..1b4b7aa373b0 100644 --- a/packages/kit/src/exports/index.js +++ b/packages/kit/src/exports/index.js @@ -1,5 +1,13 @@ import { HttpError, Redirect, ActionFailure } from '../runtime/control.js'; import { BROWSER, DEV } from 'esm-env'; +import { + add_data_suffix, + add_resolution_suffix, + has_data_suffix, + has_resolution_suffix, + strip_data_suffix, + strip_resolution_suffix +} from '../runtime/pathname.js'; export { VERSION } from '../version.js'; @@ -207,3 +215,49 @@ export function fail(status, data) { export function isActionFailure(e) { return e instanceof ActionFailure; } + +/** + * Strips possible SvelteKit-internal suffixes and trailing slashes from the URL pathname. + * Returns the normalized URL as well as a method for adding the potential suffix back + * based on a new pathname (possibly including search) or URL. + * ```js + * import { normalizeUrl } from '@sveltejs/kit'; + * + * const { url, denormalize } = normalizeUrl('/blog/post/__data.json'); + * console.log(url.pathname); // /blog/post + * console.log(denormalize('/blog/post/a')); // /blog/post/a/__data.json + * ``` + * @param {URL | string} url + * @returns {{ url: URL, wasNormalized: boolean, denormalize: (url?: string | URL) => URL }} + */ +export function normalizeUrl(url) { + url = new URL(url, 'http://internal'); + + const is_route_resolution = has_resolution_suffix(url.pathname); + const is_data_request = has_data_suffix(url.pathname); + const has_trailing_slash = url.pathname !== '/' && url.pathname.endsWith('/'); + + if (is_route_resolution) { + url.pathname = strip_resolution_suffix(url.pathname); + } else if (is_data_request) { + url.pathname = strip_data_suffix(url.pathname); + } else if (has_trailing_slash) { + url.pathname = url.pathname.slice(0, -1); + } + + return { + url, + wasNormalized: is_data_request || is_route_resolution || has_trailing_slash, + denormalize: (new_url = url) => { + new_url = new URL(new_url, url); + if (is_route_resolution) { + new_url.pathname = add_resolution_suffix(new_url.pathname); + } else if (is_data_request) { + new_url.pathname = add_data_suffix(new_url.pathname); + } else if (has_trailing_slash && !new_url.pathname.endsWith('/')) { + new_url.pathname += '/'; + } + return new_url; + } + }; +} diff --git a/packages/kit/src/exports/index.spec.js b/packages/kit/src/exports/index.spec.js new file mode 100644 index 000000000000..87fbefd35b7e --- /dev/null +++ b/packages/kit/src/exports/index.spec.js @@ -0,0 +1,53 @@ +import { normalizeUrl } from './index.js'; +import { assert, describe, it } from 'vitest'; + +describe('normalizeUrl', () => { + it('noop for regular url', () => { + const original = new URL('http://example.com/foo/bar'); + const { url, wasNormalized, denormalize } = normalizeUrl(original); + + assert.equal(wasNormalized, false); + assert.equal(url.href, original.href); + assert.equal(denormalize().href, original.href); + assert.equal(denormalize('/baz').href, 'http://example.com/baz'); + assert.equal( + denormalize('?some=query#hash').href, + 'http://example.com/foo/bar?some=query#hash' + ); + assert.equal(denormalize('http://somethingelse.com/').href, 'http://somethingelse.com/'); + assert.equal( + denormalize(new URL('http://somethingelse.com/')).href, + 'http://somethingelse.com/' + ); + }); + + it('should normalize trailing slash', () => { + const original = new URL('http://example.com/foo/bar/'); + const { url, wasNormalized, denormalize } = normalizeUrl(original); + + assert.equal(wasNormalized, true); + assert.equal(url.href, original.href.slice(0, -1)); + assert.equal(denormalize().href, original.href); + assert.equal(denormalize('/baz').href, 'http://example.com/baz/'); + }); + + it('should normalize data request route', () => { + const original = new URL('http://example.com/foo/__data.json'); + const { url, wasNormalized, denormalize } = normalizeUrl(original); + + assert.equal(wasNormalized, true); + assert.equal(url.href, 'http://example.com/foo'); + assert.equal(denormalize().href, original.href); + assert.equal(denormalize('/baz').href, 'http://example.com/baz/__data.json'); + }); + + it('should normalize route request route', () => { + const original = new URL('http://example.com/foo/__route.js'); + const { url, wasNormalized, denormalize } = normalizeUrl(original); + + assert.equal(wasNormalized, true); + assert.equal(url.href, 'http://example.com/foo'); + assert.equal(denormalize().href, original.href); + assert.equal(denormalize('/baz').href, 'http://example.com/baz/__route.js'); + }); +}); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 0b54457bdd27..ff9e8c28affe 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -2005,6 +2005,23 @@ declare module '@sveltejs/kit' { * @param e The object to check. * */ export function isActionFailure(e: unknown): e is ActionFailure; + /** + * Strips possible SvelteKit-internal suffixes and trailing slashes from the URL pathname. + * Returns the normalized URL as well as a method for adding the potential suffix back + * based on a new pathname (possibly including search) or URL. + * ```js + * import { normalizeUrl } from '@sveltejs/kit'; + * + * const { url, denormalize } = normalizeUrl('/blog/post/__data.json'); + * console.log(url.pathname); // /blog/post + * console.log(denormalize('/blog/post/a')); // /blog/post/a/__data.json + * ``` + * */ + export function normalizeUrl(url: URL | string): { + url: URL; + wasNormalized: boolean; + denormalize: (url?: string | URL) => URL; + }; export type LessThan = TNumber extends TArray["length"] ? TArray[number] : LessThan; export type NumericRange = Exclude, LessThan>; export const VERSION: string;