From e14d939477c513558e7410f3850c1936894e7af7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ramirez=20Vargas=2C=20Jos=C3=A9=20Pablo?= Date: Sun, 23 Nov 2025 14:19:11 -0600 Subject: [PATCH 1/5] feat!: Add Redirector BREAKING CHANGE --- README.md | 66 +++ src/lib/index.test.ts | 1 + src/lib/index.ts | 1 + src/lib/kernel/LocationLite.svelte.test.ts | 28 + src/lib/kernel/LocationLite.svelte.ts | 7 +- src/lib/kernel/Redirector.svelte.test.ts | 387 ++++++++++++ src/lib/kernel/Redirector.svelte.ts | 133 +++++ src/lib/kernel/RouteHelper.svelte.test.ts | 556 ++++++++++++++++++ src/lib/kernel/RouteHelper.svelte.ts | 122 ++++ src/lib/kernel/RouterEngine.svelte.ts | 119 +--- src/lib/kernel/buildHref.test.ts | 310 ++++++++++ src/lib/kernel/buildHref.ts | 30 + src/lib/kernel/calculateHref.test.ts | 106 +++- src/lib/kernel/calculateHref.ts | 21 +- .../kernel/calculateMultiHashFragment.test.ts | 364 ++++++++++++ src/lib/kernel/calculateMultiHashFragment.ts | 26 + src/lib/kernel/index.test.ts | 2 + src/lib/kernel/index.ts | 5 +- src/lib/kernel/preserveQuery.test.ts | 215 +++++++ src/lib/kernel/preserveQuery.ts | 25 +- src/lib/testing/test-utils.ts | 2 +- src/lib/testing/testWithEffect.svelte.ts | 16 + src/lib/types.ts | 45 ++ 23 files changed, 2430 insertions(+), 157 deletions(-) create mode 100644 src/lib/kernel/Redirector.svelte.test.ts create mode 100644 src/lib/kernel/Redirector.svelte.ts create mode 100644 src/lib/kernel/RouteHelper.svelte.test.ts create mode 100644 src/lib/kernel/RouteHelper.svelte.ts create mode 100644 src/lib/kernel/buildHref.test.ts create mode 100644 src/lib/kernel/buildHref.ts create mode 100644 src/lib/kernel/calculateMultiHashFragment.test.ts create mode 100644 src/lib/kernel/calculateMultiHashFragment.ts create mode 100644 src/lib/testing/testWithEffect.svelte.ts diff --git a/README.md b/README.md index 03cbb71..6ba03f3 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ [@svelte-router/kit](https://github.com/WJSoftware/svelte-router-kit) + **Electron support**: Works with Electron (all routing modes) + **Reactivity-based**: All data is reactive, reducing the need for events and imperative programming. ++ **⚡NEW! URL Redirection**: Use `Redirector` instances to route users from deprecated URL's to new URL's, even across +routing universes. **Components**: @@ -470,6 +472,70 @@ As seen, the value of the `href` property never changes. It's always a path, re At your own risk, you could use exported API like `getRouterContext()` and `setRouterContext()` to perform unholy acts on the router layouts, again, **at your own risk**. +## URL Redirection + +Create `Redirector` class instances to route users from deprecated URL's to new URL's. The redirection can even cross +the routing universe boundary. In other words, URL's from one routing universe can be redirected to a different +routing universe. + +This is a same-universe example: + +```svelte + +``` + +The constructor of the class sets a Svelte `$effect` up, so instances of this class must be created in places where +Svelte effects are acceptable, like the initialization code of a component (like in the example). + +Redirections are almost identical to route definitions, and even use the same matching algorithm. The `pattern` is +used to match the current URL (it defines the deprecated URL), while `href` defines the new URL users will be +redirected to. As seen in the example, parameters can be defined, and `href`, when written as a function, receives +the route parameters as the first argument. + +### Cross-Universe Redirection + +Crossing the universe boundary when redirecting is very simple, but there's a catch: Cleaning up the old URL. + +```svelte + +``` + +The modifications in the example are: + +1. Explicit hash value in the redirector's constructor. +2. Destination hash value specifications via options. + +Now comes the aforementioned catch: The "final" URL will be looking like this: `https://example.com/orders/123#/profile/my-orders/123`. + +There's no good way for this library to provide a safe way to "clean up" the path in the deprecated routing universe, +so it is up to consumers of this library to clean up. How? The recommendation is to tell the redirector to use +`location.goTo()` and provide a full HREF with all universes accounted for. + +See the [Redirecting](https://wjfe-n-savant.hashnode.space/wjfe-n-savant/navigating/redirecting) topic in the online +documentation for full details, including helper functions available to you. + --- [Issues Here](https://github.com/WJSoftware/svelte-router-core/issues) diff --git a/src/lib/index.test.ts b/src/lib/index.test.ts index 655db9a..7fab50d 100644 --- a/src/lib/index.test.ts +++ b/src/lib/index.test.ts @@ -19,6 +19,7 @@ describe('index', () => { 'setRouterContext', 'isRouteActive', 'activeBehavior', + 'Redirector', ]; // Act. diff --git a/src/lib/index.ts b/src/lib/index.ts index 768c4b4..ae8442b 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -14,3 +14,4 @@ export * from './RouterTrace/RouterTrace.svelte'; export { default as RouterTrace } from './RouterTrace/RouterTrace.svelte'; export * from "./public-utils.js"; export * from "./behaviors/active.svelte.js"; +export { Redirector } from "./kernel/Redirector.svelte.js"; diff --git a/src/lib/kernel/LocationLite.svelte.test.ts b/src/lib/kernel/LocationLite.svelte.test.ts index bb003b3..95e0582 100644 --- a/src/lib/kernel/LocationLite.svelte.test.ts +++ b/src/lib/kernel/LocationLite.svelte.test.ts @@ -264,4 +264,32 @@ describe("LocationLite", () => { expect(act).toThrow(); }); }); + describe('path', () => { + const initialPath = '/initial/path'; + beforeEach(() => { + browserMocks.simulateHistoryChange(undefined, `http://example.com${initialPath}`); + }); + + test("Should return the URL's path.", () => { + expect(location.path).toBe(initialPath); + }); + + test("Should update when location changes.", () => { + expect(location.path).toBe(initialPath); + const newPath = '/new/path/value'; + location.navigate(newPath); + expect(location.path).toBe(newPath); + }); + + test("Should remove the drive letter on Windows file URLs.", () => { + // Arrange. + const fileUrl = 'file:///C:/path/to/file.txt'; + + // Act. + browserMocks.simulateHistoryChange(undefined, fileUrl); + + // Assert. + expect(location.path).toBe('/path/to/file.txt'); + }); + }); }); diff --git a/src/lib/kernel/LocationLite.svelte.ts b/src/lib/kernel/LocationLite.svelte.ts index d3cbf85..dc8da78 100644 --- a/src/lib/kernel/LocationLite.svelte.ts +++ b/src/lib/kernel/LocationLite.svelte.ts @@ -14,7 +14,7 @@ import { assertAllowedRoutingMode } from "$lib/utils.js"; */ export class LocationLite implements Location { #historyApi: HistoryApi; - + hashPaths = $derived.by(() => { if (routingOptions.hashMode === 'single') { return { single: this.#historyApi.url.hash.substring(1) }; @@ -31,6 +31,11 @@ export class LocationLite implements Location { return result; }); + path = $derived.by(() => { + const hasDriveLetter = this.url.protocol.startsWith('file:') && this.url.pathname[2] === ':'; + return hasDriveLetter ? this.url.pathname.substring(3) : this.url.pathname; + }); + constructor(historyApi?: HistoryApi) { this.#historyApi = historyApi ?? new StockHistoryApi(); } diff --git a/src/lib/kernel/Redirector.svelte.test.ts b/src/lib/kernel/Redirector.svelte.test.ts new file mode 100644 index 0000000..47dfd78 --- /dev/null +++ b/src/lib/kernel/Redirector.svelte.test.ts @@ -0,0 +1,387 @@ +import { afterAll, afterEach, beforeAll, describe, expect, vi, type MockInstance } from "vitest"; +import { testWithEffect as test } from "$test/testWithEffect.svelte.js"; +import { ALL_HASHES, ROUTING_UNIVERSES } from "$test/test-utils.js"; +import { init } from "$lib/init.js"; +import type { Hash, PatternRouteInfo, RedirectedRouteInfo } from "$lib/types.js"; +import { resolveHashValue } from "./resolveHashValue.js"; +import { Redirector } from "./Redirector.svelte.js"; +import { location } from "./Location.js"; +import { flushSync } from "svelte"; + +ROUTING_UNIVERSES.forEach((universe) => { + describe(`Redirector - ${universe.text}`, () => { + let cleanup: () => void; + let resolvedHash: Hash; + let ruPath: () => string; + let navigateSpy: MockInstance; + let goToSpy: MockInstance; + beforeAll(() => { + cleanup = init(universe); + resolvedHash = resolveHashValue(universe.hash); + switch (resolvedHash) { + case ALL_HASHES.path: + ruPath = () => location.path; + break; + case ALL_HASHES.single: + ruPath = () => location.hashPaths.single; + break; + case ALL_HASHES.multi: + ruPath = () => location.hashPaths[ALL_HASHES.multi]; + break; + } + navigateSpy = vi.spyOn(location, 'navigate'); + goToSpy = vi.spyOn(location, 'goTo'); + }); + afterAll(() => { + cleanup(); + }); + afterEach(() => { + location.goTo('/'); + vi.clearAllMocks(); + }); + + describe("redirections", () => { + const tests: (RedirectedRouteInfo & { + triggerUrl: string; + expectedPath: string; + text: string; + })[] = [ + { + triggerUrl: '/old/path', + pattern: '/old/path', + href: '/new/path', + expectedPath: '/new/path', + text: "Static pattern; static href" + }, + { + pattern: '/old-path/:id', + triggerUrl: '/old-path/123', + expectedPath: '/new-path/123', + href: (rp) => `/new-path/${rp?.id}`, + text: "Parameterized pattern; dynamic href" + }, + { + pattern: '/old-path/*', + triggerUrl: '/old-path/any/number/of/segments', + expectedPath: '/new-path/any/number/of/segments', + href: (rp) => `/new-path${rp?.rest}`, + text: "Rest parameter; dynamic href" + }, + { + pattern: '/conditional/:id', + triggerUrl: '/conditional/123', + expectedPath: '/allowed/123', + href: (rp) => `/allowed/${rp?.id}`, + and: (rp) => (rp?.id as number) > 100, + text: "Conditional redirection with and predicate (allowed)" + }, + ]; + tests.forEach((tc) => { + test(`Should navigate to ${tc.expectedPath} under conditions: ${tc.text}.`, () => { + // Arrange. + const newPath = "/new-path/123"; + location.navigate(tc.triggerUrl, { hash: universe.hash }); + const redirector = new Redirector(universe.hash); + navigateSpy.mockClear(); + + // Act. + redirector.redirections.push({ + ...tc + }); + flushSync(); + + // Assert. + expect(navigateSpy).toHaveBeenCalledTimes(1); + expect(ruPath()).toBe(tc.expectedPath); + }); + }); + }); + test("Should use 'goTo' for navigation when specified in redirection info.", () => { + // Arrange. + location.navigate('/old-path', { hash: universe.hash }); + navigateSpy.mockClear(); + const redirector = new Redirector(universe.hash); + + // Act. + redirector.redirections.push({ + pattern: '/old-path', + href: '/new-path', + goTo: true, + }); + flushSync(); + + // Assert. + expect(goToSpy).toHaveBeenCalledTimes(1); + expect(navigateSpy).toHaveBeenCalledTimes(0); + }); + + test("Should not redirect when 'and' predicate returns false.", () => { + // Arrange. + location.navigate('/conditional/50', { hash: universe.hash }); + const redirector = new Redirector(universe.hash); + navigateSpy.mockClear(); + + // Act. + redirector.redirections.push({ + pattern: '/conditional/:id', + href: '/not-allowed', + and: (rp) => (rp?.id as number) > 100, + }); + flushSync(); + + // Assert. + expect(navigateSpy).toHaveBeenCalledTimes(0); + expect(ruPath()).toBe('/conditional/50'); // Should stay on original path + }); + + test("Should redirect with first matching redirection when multiple match.", () => { + // Arrange. + location.navigate('/multi/test', { hash: universe.hash }); + const redirector = new Redirector(universe.hash); + navigateSpy.mockClear(); + + // Act. + redirector.redirections.push( + { + pattern: '/multi/*', + href: '/first-match', + }, + { + pattern: '/multi/test', + href: '/second-match', + } + ); + flushSync(); + + // Assert. + expect(navigateSpy).toHaveBeenCalledTimes(1); + expect(ruPath()).toBe('/first-match'); // Should use first matching redirection + }); + + test("Should respect replace option from constructor.", () => { + // Arrange. + location.navigate('/test-replace', { hash: universe.hash }); + const redirector = new Redirector(universe.hash, { replace: false }); + navigateSpy.mockClear(); + + // Act. + redirector.redirections.push({ + pattern: '/test-replace', + href: '/replaced', + }); + flushSync(); + + // Assert. + expect(navigateSpy).toHaveBeenCalledWith('/replaced', expect.objectContaining({ + replace: false + })); + }); + + test("Should pass through redirection options to navigation method.", () => { + // Arrange. + location.navigate('/with-options', { hash: universe.hash }); + const redirector = new Redirector(universe.hash); + navigateSpy.mockClear(); + + // Act. + redirector.redirections.push({ + pattern: '/with-options', + href: '/target', + options: { preserveQuery: true, state: { custom: 'data' } } + }); + flushSync(); + + // Assert. + expect(navigateSpy).toHaveBeenCalledWith('/target', expect.objectContaining({ + preserveQuery: true, + state: { custom: 'data' } + })); + }); + + test("Should react to changes in additions to 'redirections' without a URL change.", () => { + // Arrange. + location.navigate('/test-reactivity', { hash: universe.hash }); + const redirector = new Redirector(universe.hash); + + // Add initial redirection that won't match + redirector.redirections.push({ + pattern: '/different-path', + href: '/not-relevant' + }); + flushSync(); + navigateSpy.mockClear(); + + // Act. + redirector.redirections.push({ + pattern: '/test-reactivity', + href: '/should-redirect' + }); + flushSync(); + + // Assert. + expect(navigateSpy).toHaveBeenCalledTimes(1); + }); + + test("Should react to changes in the values of a redirection.", () => { + // Arrange. + location.navigate('/test-reactivity', { hash: universe.hash }); + const redirector = new Redirector(universe.hash); + redirector.redirections.push({ + pattern: '/different-path', + href: '/punch-line' + }); + flushSync(); + navigateSpy.mockClear(); + + // Act. + (redirector.redirections[0] as PatternRouteInfo).pattern = '/test-reactivity'; + flushSync(); + + // Assert. + expect(navigateSpy).toHaveBeenCalledTimes(1); + expect(ruPath()).toBe('/punch-line'); + }); + }); +}); + +describe("Cross-universe Redirection", () => { + describe("Path/Hash Scenarios", () => { + let cleanup: () => void; + let navigateSpy: MockInstance; + let goToSpy: MockInstance; + + beforeAll(() => { + // Initialize with path routing as the base universe + cleanup = init({ defaultHash: false }); + navigateSpy = vi.spyOn(location, 'navigate'); + goToSpy = vi.spyOn(location, 'goTo'); + }); + + afterAll(() => { + cleanup(); + }); + + afterEach(() => { + location.goTo('/'); + vi.clearAllMocks(); + }); + + test("Should redirect from path universe to hash universe.", () => { + // Arrange. + location.navigate('/old-path-route', { hash: false }); // Path universe navigation + const redirector = new Redirector(false); // Monitor path universe + flushSync(); + navigateSpy.mockClear(); + console.debug('Location before redirection:', location.url.href); + + // Act. + redirector.redirections.push({ + pattern: '/old-path-route', + href: '/new-hash-route', + options: { hash: true } + }); + flushSync(); + + // Assert. + expect(navigateSpy).toHaveBeenCalledWith('/new-hash-route', expect.objectContaining({ + hash: true, + })); + expect(location.hashPaths.single).toBe('/new-hash-route'); + }); + + test("Should redirect from hash universe to path universe.", () => { + // Arrange. + location.navigate('/old-hash-route', { hash: true }); // Hash universe navigation + const redirector = new Redirector(true); // Monitor hash universe + navigateSpy.mockClear(); + + // Act. + redirector.redirections.push({ + pattern: '/old-hash-route', + href: '/new-path-route', + options: { hash: false } // Target path universe + }); + flushSync(); + + // Assert. + expect(navigateSpy).toHaveBeenCalledWith('/new-path-route', expect.objectContaining({ + hash: false, + replace: true + })); + expect(location.path).toBe('/new-path-route'); + }); + }); + describe("Multi-Hash Scenarios", () => { + let cleanup: () => void; + let navigateSpy: MockInstance; + let goToSpy: MockInstance; + + beforeAll(() => { + // Initialize with path routing as the base universe + cleanup = init({ defaultHash: false, hashMode: 'multi' }); + navigateSpy = vi.spyOn(location, 'navigate'); + goToSpy = vi.spyOn(location, 'goTo'); + }); + + afterAll(() => { + cleanup(); + }); + + afterEach(() => { + location.goTo('/'); + vi.clearAllMocks(); + }); + const tests: { + hash: Hash; + destinationHash: Hash; + finalPath: () => string; + sourceName: string; + destName: string; + }[] = [ + { + hash: false, + destinationHash: 'p1', + finalPath: () => location.hashPaths.p1, + sourceName: 'the path universe', + destName: 'a named hash universe', + }, + { + hash: 'p1', + destinationHash: false, + finalPath: () => location.path, + sourceName: 'a named hash universe', + destName: 'the path universe', + }, + { + hash: 'p1', + destinationHash: 'p2', + finalPath: () => location.hashPaths.p2, + sourceName: 'a named hash universe', + destName: 'another named hash universe', + }, + ]; + tests.forEach((tc) => { + test(`Should redirect from ${tc.sourceName} to ${tc.destName}.`, () => { + // Arrange. + location.navigate('/old-path-route', { hash: tc.hash }); + const redirector = new Redirector(tc.hash); + flushSync(); + navigateSpy.mockClear(); + + // Act. + redirector.redirections.push({ + pattern: '/old-path-route', + href: '/new-hash-route', + options: { hash: tc.destinationHash } + }); + flushSync(); + + // Assert. + expect(navigateSpy).toHaveBeenCalledWith('/new-hash-route', expect.objectContaining({ + hash: tc.destinationHash, + })); + expect(tc.finalPath()).toBe('/new-hash-route'); + }); + }); + }); +}); diff --git a/src/lib/kernel/Redirector.svelte.ts b/src/lib/kernel/Redirector.svelte.ts new file mode 100644 index 0000000..be1fd41 --- /dev/null +++ b/src/lib/kernel/Redirector.svelte.ts @@ -0,0 +1,133 @@ +import type { Hash, ParameterValue, RedirectedRouteInfo } from "$lib/types.js"; +import { RouteHelper } from "./RouteHelper.svelte.js"; +import { location } from "./Location.js"; +import { resolveHashValue } from "./resolveHashValue.js"; +import { untrack } from "svelte"; + +/** + * Options for the Redirector class. + */ +export type RedirectorOptions = { + replace?: boolean; +} + +/** + * Default redirector options. + */ +const defaultRedirectorOptions: RedirectorOptions = { + replace: true +}; + +/** + * Class capable of performing URL redirections according to the defined redirection data provided. + * + * Both the redirection list and the current URL are reactive, so redirections are automatically performed + * when either changes. + * + * **IMPORTANT**: Since this is a reactivity-based redirector that registers an effect during construction, it must be + * initialized within a reactive context (e.g., inside the initialization script of a component or anywhere where + * `$effect.tracking()` returns `true`). + * + * ### Sveltekit Developers + * + * It is best to condition the creation of the class instance to only run on the client side, as Sveltekit's + * server-side rendering doesn't run effects, and an effect is what drives redirection. Save some CPU cycles by + * only creating the instance on the client side, for example: + * + * ```svelte + * + * ``` + */ +export class Redirector { + /** + * Redirector options. + */ + #options; + /** + * List of redirections to perform. Add or remove items from this array. The array is reactive, and adding or + * removing items can trigger immediate redirections. + * + * ### How It Works + * + * Redirection definitions are almost identical to route definitions, and are "matched" with the exact same + * algorithm used for routes. The `path` property specifies the old path to match, and the `href` property + * specifies the new URL to navigate to when a match is found. + * + * Redirection definitions even support the `and` predicate property, which allows more complex redirection + * scenarios, and works identically to the `and` property in route definitions. It even extracts "route + * parameters" when the path matches, and those are available to both the `and` predicate and the `href` property + * when defined as a function. + * + * @example + * ```svelte + * + * ``` + */ + readonly redirections; + /** + * Route helper used to parse and test routes. + */ + #routeHelper; + /** + * The resolved hash value used for this redirector. + */ + #hash: Hash; + /** + * The route patterns derived from the redirection list. + */ + #routePatterns = $derived.by(() => this.redirections.map((url) => this.#routeHelper.parseRoutePattern(url))); + /** + * Initializes a new instance of this class. + * @param hash Resolved hash value that will be used for route testing and navigation if no navigation-specific + * hash value is provided via the redirection options. + * @param options Redirector options. + */ + constructor(hash?: Hash | undefined, options?: RedirectorOptions) { + this.#options = { ...defaultRedirectorOptions, ...options }; + this.#hash = resolveHashValue(hash); + this.redirections = $state([]); + this.#routeHelper = new RouteHelper(this.#hash); + + $effect(() => { + for (let i = 0; i < this.redirections.length; ++i) { + const [match, routeParams] = this.#routeHelper.testRoute(this.#routePatterns[i]); + if (match) { + untrack(() => this.#navigate(this.redirections[i], routeParams)); + break; + } + } + }); + } + /** + * Performs the navigation according to the provided redirection information. + * @param redirection Redirection information to use for navigation. + * @param routeParams Route parameters obtained during route matching. + */ + #navigate(redirection: RedirectedRouteInfo, routeParams: Record | undefined) { + const url = typeof redirection.href === 'function' ? + redirection.href(routeParams) : + redirection.href; + location[(redirection.goTo ? 'goTo' : 'navigate')](url, { + hash: this.#hash, + replace: this.#options.replace, + ...redirection.options, + }); + } +} diff --git a/src/lib/kernel/RouteHelper.svelte.test.ts b/src/lib/kernel/RouteHelper.svelte.test.ts new file mode 100644 index 0000000..2faf0f2 --- /dev/null +++ b/src/lib/kernel/RouteHelper.svelte.test.ts @@ -0,0 +1,556 @@ +import { ROUTING_UNIVERSES, setupBrowserMocks, type RoutingUniverse } from "../testing/test-utils.js"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; +import { RouteHelper } from "./RouteHelper.svelte.js"; +import { resolveHashValue } from "./resolveHashValue.js"; +import { location } from "./Location.js"; +import { init } from "$lib/init.js"; +import type { Hash } from "$lib/types.js"; + +ROUTING_UNIVERSES.forEach((universe) => { + describe(`RouteHelper - ${universe.text}`, () => { + let cleanup: () => void; + let resolvedHash: Hash; + let routeHelper: RouteHelper; + beforeAll(() => { + cleanup = init(universe); + resolvedHash = resolveHashValue(universe.hash ?? universe.defaultHash); + }); + afterAll(() => { + cleanup(); + }); + beforeEach(() => { + routeHelper = new RouteHelper(resolvedHash); + }); + + describe("testPath", () => { + describe("Existing Path", () => { + const expectedPath = "/some/path"; + const unexpectedPath = "/unexpected/stuff"; + let cleanup: () => void; + beforeAll(() => { + const testPaths = { + 'IPR': `${expectedPath}#${unexpectedPath}`, + 'PR': `${expectedPath}#${unexpectedPath}`, + 'IHR': `${unexpectedPath}#${expectedPath}`, + 'HR': `${unexpectedPath}#${expectedPath}`, + 'IMHR': `${unexpectedPath}#unrelated=/a/b;${resolvedHash}=${expectedPath}`, + 'MHR': `${unexpectedPath}#unrelated=/a/b;${resolvedHash}=${expectedPath}`, + } as const; + const initialPath = `http://example.com${testPaths[universe.text]}`; + const mocks = setupBrowserMocks(initialPath, location); + cleanup = mocks.cleanup; + }); + afterAll(() => { + cleanup(); + }); + + test("Should return the expected path.", () => { + expect(routeHelper.testPath).toBe(expectedPath); + }); + }); + describe("No Path", () => { + const unexpectedPath = "/unexpected/stuff"; + let routeHelper: RouteHelper; + let cleanup: () => void; + beforeAll(() => { + const testPaths = { + 'IPR': `#/${unexpectedPath}`, + 'PR': `#/${unexpectedPath}`, + 'IHR': `${unexpectedPath}`, + 'HR': `${unexpectedPath}`, + 'IMHR': `${unexpectedPath}#unrelated=/a/b`, + 'MHR': `${unexpectedPath}#unrelated=/a/b`, + } as const; + const initialPath = `http://example.com${testPaths[universe.text]}`; + const mocks = setupBrowserMocks(initialPath, location); + cleanup = mocks.cleanup; + }); + afterAll(() => { + cleanup(); + }); + beforeEach(() => { + routeHelper = new RouteHelper(resolvedHash); + }); + + test("Should return a single slash when no path exists.", () => { + expect(routeHelper.testPath).toBe("/"); + }); + }); + describe("No trailing slash", () => { + const expectedPath = "/some/path"; + let routeHelper: RouteHelper; + let cleanup: () => void; + beforeAll(() => { + const testPaths = { + 'IPR': `${expectedPath}/#trailing/slash/`, + 'PR': `${expectedPath}/#trailing/slash/`, + 'IHR': `trailing/slash/#${expectedPath}/`, + 'HR': `trailing/slash/#${expectedPath}/`, + 'IMHR': `trailing/slash/#unrelated=/a/b;${resolvedHash}=${expectedPath}/`, + 'MHR': `trailing/slash/#unrelated=/a/b;${resolvedHash}=${expectedPath}/`, + } as const; + const initialPath = `http://example.com${testPaths[universe.text]}`; + const mocks = setupBrowserMocks(initialPath, location); + cleanup = mocks.cleanup; + }); + afterAll(() => { + cleanup(); + }); + beforeEach(() => { + routeHelper = new RouteHelper(resolvedHash); + }); + test("Should return the expected path without trailing slash.", () => { + expect(routeHelper.testPath).toBe(expectedPath); + }); + }); + describe("Reactivity", () => { + let mocks: ReturnType; + const initialPath = '/initial/path'; + const unexpectedPath = '/unexpected/stuff'; + let testPaths: Record; + beforeAll(() => { + testPaths = { + 'IPR': `${initialPath}#${unexpectedPath}`, + 'PR': `${initialPath}#${unexpectedPath}`, + 'IHR': `${unexpectedPath}#${initialPath}`, + 'HR': `${unexpectedPath}#${initialPath}`, + 'IMHR': `${unexpectedPath}#unrelated=/a/b;${resolvedHash}=${initialPath}`, + 'MHR': `${unexpectedPath}#unrelated=/a/b;${resolvedHash}=${initialPath}`, + }; + mocks = setupBrowserMocks(`http://example.com${testPaths[universe.text]}`, location); + }); + afterAll(() => { + mocks.cleanup(); + }); + afterEach(() => { + location.goTo(`${testPaths[universe.text]}`); + }); + + test("Should update when location changes.", () => { + expect(routeHelper.testPath).toBe(initialPath); + const newPath = '/new/path/value'; + location.navigate(newPath, { hash: universe.hash }); + expect(routeHelper.testPath).toBe(newPath); + }); + }); + }); + }); +}); + +describe("RouteHelper", () => { + let routeHelper: RouteHelper; + + beforeEach(() => { + // Use path routing (false) for these universe-independent tests + routeHelper = new RouteHelper(false); + }); + + describe("parseRoutePattern", () => { + describe("Default Props", () => { + test("Should return empty regex when pattern is not provided.", () => { + // Arrange + const routeInfo = { and: undefined, ignoreForFallback: false }; + + // Act + const result = routeHelper.parseRoutePattern(routeInfo); + + // Assert + expect(result.regex).toBeUndefined(); + expect(result.and).toBeUndefined(); + expect(result.ignoreForFallback).toBe(false); + }); + + test("Should set ignoreForFallback to false by default when not provided.", () => { + // Arrange + const routeInfo = { pattern: "/test" }; + + // Act + const result = routeHelper.parseRoutePattern(routeInfo); + + // Assert + expect(result.ignoreForFallback).toBe(false); + }); + + test("Should preserve and predicate function when provided.", () => { + // Arrange + const andPredicate = () => true; + const routeInfo = { pattern: "/test", and: andPredicate }; + + // Act + const result = routeHelper.parseRoutePattern(routeInfo); + + // Assert + expect(result.and).toBe(andPredicate); + }); + }); + + describe("Explicit Props", () => { + test.each([ + { pattern: "/", expectedRegex: "^\\/$", description: "root path" }, + { pattern: "/test", expectedRegex: "^\\/test$", description: "simple path" }, + { pattern: "/test/path", expectedRegex: "^\\/test\\/path$", description: "nested path" }, + { pattern: "/api/v1/users", expectedRegex: "^\\/api\\/v1\\/users$", description: "multi-segment path" } + ])("Should create correct regex for $description .", ({ pattern, expectedRegex }) => { + // Arrange + const routeInfo = { pattern }; + + // Act + const result = routeHelper.parseRoutePattern(routeInfo); + + // Assert + expect(result.regex?.source).toBe(expectedRegex); + expect(result.regex?.flags).toBe("i"); // Case insensitive by default + }); + + test.each([ + { pattern: "/:id", expectedRegex: "^\\/(?[^/]+)$", description: "single parameter" }, + { pattern: "/user/:userId", expectedRegex: "^\\/user\\/(?[^/]+)$", description: "parameter in path" }, + { pattern: "/:category/:id", expectedRegex: "^\\/(?[^/]+)\\/(?[^/]+)$", description: "multiple parameters" }, + { pattern: "/api-:version", expectedRegex: "^\\/api-(?[^/]+)$", description: "parameter with prefix" }, + { pattern: "/user-:id/profile", expectedRegex: "^\\/user-(?[^/]+)\\/profile$", description: "parameter with prefix and suffix" } + ])("Should create correct regex for $description .", ({ pattern, expectedRegex }) => { + // Arrange + const routeInfo = { pattern }; + + // Act + const result = routeHelper.parseRoutePattern(routeInfo); + + // Assert + expect(result.regex?.source).toBe(expectedRegex); + }); + + test.each([ + { pattern: "/:id?", expectedRegex: "^\\/?(?:(?[^/]+))?$", description: "optional parameter" }, + { pattern: "/user/:id?", expectedRegex: "^\\/user\\/?(?:(?[^/]+))?$", description: "optional parameter with leading slash" }, + { pattern: "/:category/:id?", expectedRegex: "^\\/(?[^/]+)\\/?(?:(?[^/]+))?$", description: "required and optional parameters" }, + { pattern: "/:category?/:id", expectedRegex: "^\\/?(?:(?[^/]+))?\\/(?[^/]+)$", description: "optional then required parameters" } + ])("Should create correct regex for $description .", ({ pattern, expectedRegex }) => { + // Arrange + const routeInfo = { pattern }; + + // Act + const result = routeHelper.parseRoutePattern(routeInfo); + + // Assert + expect(result.regex?.source).toBe(expectedRegex); + }); + + test.each([ + { pattern: "/files/*", expectedRegex: "^\\/files(?.*)$", description: "rest parameter" }, + { pattern: "/api/v1/*", expectedRegex: "^\\/api\\/v1(?.*)$", description: "rest parameter in nested path" }, + { pattern: "/*", expectedRegex: "^(?.*)$", description: "root rest parameter" } + ])("Should create correct regex for $description .", ({ pattern, expectedRegex }) => { + // Arrange + const routeInfo = { pattern }; + + // Act + const result = routeHelper.parseRoutePattern(routeInfo); + + // Assert + expect(result.regex?.source).toBe(expectedRegex); + }); + + test.each([ + { caseSensitive: true, expectedFlags: "", description: "case sensitive" }, + { caseSensitive: false, expectedFlags: "i", description: "case insensitive" } + ])("Should create regex with correct flags for $description .", ({ caseSensitive, expectedFlags }) => { + // Arrange + const routeInfo = { pattern: "/test", caseSensitive }; + + // Act + const result = routeHelper.parseRoutePattern(routeInfo); + + // Assert + expect(result.regex?.flags).toBe(expectedFlags); + }); + + test.each([ + { ignoreForFallback: true, expected: true, description: "explicit true" }, + { ignoreForFallback: false, expected: false, description: "explicit false" } + ])("Should set ignoreForFallback correctly when $description .", ({ ignoreForFallback, expected }) => { + // Arrange + const routeInfo = { pattern: "/test", ignoreForFallback }; + + // Act + const result = routeHelper.parseRoutePattern(routeInfo); + + // Assert + expect(result.ignoreForFallback).toBe(expected); + }); + }); + + describe("Base Path Integration", () => { + test.each([ + { basePath: "/", pattern: "/test", expectedRegex: "^\\/test$", description: "root base path" }, + { basePath: "/api", pattern: "/users", expectedRegex: "^\\/api\\/users$", description: "simple base path" }, + { basePath: "/api/v1", pattern: "/users/:id", expectedRegex: "^\\/api\\/v1\\/users\\/(?[^/]+)$", description: "nested base path with parameters" }, + { basePath: "/app", pattern: "/", expectedRegex: "^\\/app$", description: "root pattern with base path" }, + { basePath: "/api/", pattern: "/users/", expectedRegex: "^\\/api\\/users$", description: "trailing slashes handled" } + ])("Should join base path correctly for $description .", ({ basePath, pattern, expectedRegex }) => { + // Arrange + const routeInfo = { pattern }; + + // Act + const result = routeHelper.parseRoutePattern(routeInfo, basePath); + + // Assert + expect(result.regex?.source).toBe(expectedRegex); + }); + }); + + describe("Special Characters Escaping", () => { + test.each([ + { pattern: "/test.html", expectedRegex: "^\\/test\\.html$", description: "dot character" }, + { pattern: "/api+v1", expectedRegex: "^\\/api\\+v1$", description: "plus character" }, + { pattern: "/query^start", expectedRegex: "^\\/query\\^start$", description: "caret character" }, + { pattern: "/data$end", expectedRegex: "^\\/data\\$end$", description: "dollar character" }, + { pattern: "/path{test}", expectedRegex: "^\\/path\\{test\\}$", description: "curly braces" }, + { pattern: "/file(1)", expectedRegex: "^\\/file\\(1\\)$", description: "parentheses" }, + { pattern: "/arr[0]", expectedRegex: "^\\/arr\\[0\\]$", description: "square brackets" }, + { pattern: "/back\\slash", expectedRegex: "^\\/back\\\\slash$", description: "backslash" } + ])("Should escape $description correctly .", ({ pattern, expectedRegex }) => { + // Arrange + const routeInfo = { pattern }; + + // Act + const result = routeHelper.parseRoutePattern(routeInfo); + + // Assert + expect(result.regex?.source).toBe(expectedRegex); + }); + }); + }); + + describe("testRoute", () => { + // Note: The testRoute method uses this.testPath internally, which is a derived value + // that depends on location.path. Since proper location setup is complex and the location + // is tested separately in other test files, we focus on testing the core logic that + // doesn't depend on the testPath property. + + describe("Default Props", () => { + test("Should match when no regex is provided.", () => { + // Arrange + const routeMatchInfo = {}; + + // Act + const [match, params] = routeHelper.testRoute(routeMatchInfo); + + // Assert + expect(match).toBe(true); + expect(params).toBeUndefined(); + }); + }); + + describe("And Predicate Integration", () => { + test("Should match with and predicate when no regex provided.", () => { + // Arrange + const andPredicate = vi.fn(() => true); + const routeMatchInfo = { and: andPredicate }; + + // Act + const [match, params] = routeHelper.testRoute(routeMatchInfo); + + // Assert + expect(match).toBe(true); + expect(andPredicate).toHaveBeenCalledWith(undefined); + expect(params).toBeUndefined(); + }); + + test("Should not match with and predicate when no regex provided and predicate returns false.", () => { + // Arrange + const andPredicate = vi.fn(() => false); + const routeMatchInfo = { and: andPredicate }; + + // Act + const [match, params] = routeHelper.testRoute(routeMatchInfo); + + // Assert + expect(match).toBe(false); + expect(andPredicate).toHaveBeenCalledWith(undefined); + expect(params).toBeUndefined(); + }); + }); + + describe("Regex Execution Logic", () => { + // These tests verify the core regex matching and parameter parsing logic + // by creating a mock RouteHelper with a fixed testPath + + class MockRouteHelper extends RouteHelper { + readonly mockTestPath: string; + + constructor(mockTestPath: string) { + super(false); + this.mockTestPath = mockTestPath; + // Override the derived testPath by replacing it + Object.defineProperty(this, 'testPath', { + get: () => this.mockTestPath, + enumerable: true, + configurable: true + }); + } + } + + test.each([ + { + testPath: "/user/123", + regex: /^\/user\/(?\d+)$/, + expectedMatch: true, + expectedParams: { id: 123 }, + description: "numeric parameter matching" + }, + { + testPath: "/user/abc", + regex: /^\/user\/(?[^/]+)$/, + expectedMatch: true, + expectedParams: { id: "abc" }, + description: "string parameter matching" + }, + { + testPath: "/post/123/comment/456", + regex: /^\/post\/(?\d+)\/comment\/(?\d+)$/, + expectedMatch: true, + expectedParams: { postId: 123, commentId: 456 }, + description: "multiple numeric parameters" + }, + { + testPath: "/files/docs/readme.txt", + regex: /^\/files\/(?.*)$/, + expectedMatch: true, + expectedParams: { rest: "docs/readme.txt" }, + description: "rest parameter matching" + }, + { + testPath: "/different", + regex: /^\/user\/(?\d+)$/, + expectedMatch: false, + expectedParams: undefined, + description: "non-matching path" + } + ])("Should handle $description correctly .", ({ testPath, regex, expectedMatch, expectedParams }) => { + // Arrange + const mockHelper = new MockRouteHelper(testPath); + const routeMatchInfo = { regex }; + + // Act + const [match, params] = mockHelper.testRoute(routeMatchInfo); + + // Assert + expect(match).toBe(expectedMatch); + expect(params).toEqual(expectedParams); + }); + + test.each([ + { value: "123", expected: 123, description: "string number to number" }, + { value: "true", expected: true, description: "string true to boolean" }, + { value: "false", expected: false, description: "string false to boolean" }, + { value: "hello", expected: "hello", description: "regular string unchanged" }, + { value: "", expected: "", description: "empty string unchanged" } + ])("Should parse parameter values correctly: $description .", ({ value, expected }) => { + // Arrange + const mockHelper = new MockRouteHelper(`/test/${value}`); + const regex = /^\/test\/(?.*)$/; + const routeMatchInfo = { regex }; + + // Act + const [match, params] = mockHelper.testRoute(routeMatchInfo); + + // Assert + expect(match).toBe(true); + expect(params?.param).toBe(expected); + }); + + test("Should decode URI components in parameters.", () => { + // Arrange + const encodedValue = "hello%20world%21"; // "hello world!" + const expectedValue = "hello world!"; + const mockHelper = new MockRouteHelper(`/test/${encodedValue}`); + const regex = /^\/test\/(?[^/]+)$/; + const routeMatchInfo = { regex }; + + // Act + const [match, params] = mockHelper.testRoute(routeMatchInfo); + + // Assert + expect(match).toBe(true); + expect(params?.param).toBe(expectedValue); + }); + + test("Should remove undefined parameters from result.", () => { + // Arrange + const mockHelper = new MockRouteHelper("/user/123"); + const regex = /^\/user\/(?\d+)(?:\/(?[^/]+))?$/; + const routeMatchInfo = { regex }; + + // Act + const [match, params] = mockHelper.testRoute(routeMatchInfo); + + // Assert + expect(match).toBe(true); + expect(params).toEqual({ id: 123 }); + expect(params).not.toHaveProperty('optional'); + }); + + test("Should handle empty route groups correctly.", () => { + // Arrange + const mockHelper = new MockRouteHelper("/test"); + const regex = /^\/test$/; // No groups + const routeMatchInfo = { regex }; + + // Act + const [match, params] = mockHelper.testRoute(routeMatchInfo); + + // Assert + expect(match).toBe(true); + expect(params).toBeUndefined(); // No groups means no params + }); + + test("Should pass parameters to and predicate.", () => { + // Arrange + const mockHelper = new MockRouteHelper("/user/123"); + const regex = /^\/user\/(?\d+)$/; + const andPredicate = vi.fn(() => true); + const routeMatchInfo = { regex, and: andPredicate }; + + // Act + const [match, params] = mockHelper.testRoute(routeMatchInfo); + + // Assert + expect(match).toBe(true); + expect(andPredicate).toHaveBeenCalledWith({ id: 123 }); + expect(params).toEqual({ id: 123 }); + }); + + test("Should not match when and predicate returns false.", () => { + // Arrange + const mockHelper = new MockRouteHelper("/user/123"); + const regex = /^\/user\/(?\d+)$/; + const andPredicate = vi.fn(() => false); + const routeMatchInfo = { regex, and: andPredicate }; + + // Act + const [match, params] = mockHelper.testRoute(routeMatchInfo); + + // Assert + expect(match).toBe(false); + expect(andPredicate).toHaveBeenCalledWith({ id: 123 }); + expect(params).toEqual({ id: 123 }); + }); + + test("Should not call and predicate when regex doesn't match.", () => { + // Arrange + const mockHelper = new MockRouteHelper("/different"); + const regex = /^\/user\/(?\d+)$/; + const andPredicate = vi.fn(() => true); + const routeMatchInfo = { regex, and: andPredicate }; + + // Act + const [match, params] = mockHelper.testRoute(routeMatchInfo); + + // Assert + expect(match).toBe(false); + expect(andPredicate).not.toHaveBeenCalled(); + expect(params).toBeUndefined(); + }); + }); + }); +}); diff --git a/src/lib/kernel/RouteHelper.svelte.ts b/src/lib/kernel/RouteHelper.svelte.ts new file mode 100644 index 0000000..11b64a2 --- /dev/null +++ b/src/lib/kernel/RouteHelper.svelte.ts @@ -0,0 +1,122 @@ +import type { AndUntyped, Hash, PatternRouteInfo, RouteStatus } from "$lib/types.js"; +import { location } from "./Location.js"; + +function noTrailingSlash(path: string) { + return path !== '/' && path.endsWith('/') ? path.slice(0, -1) : path; +} + +function hasLeadingSlash(paths: (string | undefined)[]) { + for (let path of paths) { + if (!path) { + continue; + } + return path.startsWith('/'); + } + return false; +} + +/** + * Joins the provided paths into a single path. + * @param paths Paths to join. + * @returns The joined path. + */ +export function joinPaths(...paths: string[]) { + const result = paths.reduce((acc, path, index) => { + const trimmedPath = (path ?? '').replace(/^\/|\/$/g, ''); + return acc + (index > 0 && !acc.endsWith('/') && trimmedPath.length > 0 ? '/' : '') + trimmedPath; + }, hasLeadingSlash(paths) ? '/' : ''); + return noTrailingSlash(result); +} + +function escapeRegExp(string: string): string { + return string.replace(/[.+^${}()|[\]\\]/g, '\\$&'); +} + +function tryParseValue(value: string) { + if (value === '' || value === undefined || value === null) { + return value; + } + const num = Number(value); + if (!isNaN(num)) { + return num; + } + if (value === 'true') { + return true; + } + if (value === 'false') { + return false; + } + return value; +} + +const identifierRegex = /(\/)?:([a-zA-Z_]\w*)(\?)?/g; +const paramNamePlaceholder = "paramName"; +const paramValueRegex = `(?<${paramNamePlaceholder}>[^/]+)`; +const restParamRegex = /\/\*$/; + +/** + * Helper class for route parsing and testing (route matching). + */ +export class RouteHelper { + /** + * The hash path ID this route helper uses (if any). Undefined if using path routing. + */ + #hashId; + /** + * Gets the test path this route helper uses to test route paths. Its value depends on the routing mode (universe). + */ + readonly testPath = $derived.by(() => noTrailingSlash(this.#hashId ? (location.hashPaths[this.#hashId] || '/') : location.path)); + /** + * Initializes a new instance of this class. + * @param hash Resolved hash value to use for (almost) all functions. + */ + constructor(hash: Hash) { + this.#hashId = typeof hash === 'string' ? hash : (hash ? 'single' : undefined); + } + /** + * Parses the string pattern in the provided route information object into a regular expression. + * @param routeInfo Pattern route information to parse. + * @returns An object with the regular expression, the optional predicate function, and the ignoreForFallback flag. + */ + parseRoutePattern(routeInfo: PatternRouteInfo, basePath?: string): { regex?: RegExp; and?: AndUntyped; ignoreForFallback: boolean; } { + if (!routeInfo.pattern) { + return { + and: routeInfo.and, + ignoreForFallback: !!routeInfo.ignoreForFallback + } + } + const fullPattern = joinPaths(basePath || '/', routeInfo.pattern === '/' ? '' : routeInfo.pattern); + const escapedPattern = escapeRegExp(fullPattern); + let regexPattern = escapedPattern.replace(identifierRegex, (_match, startingSlash, paramName, optional, offset) => { + let regex = paramValueRegex.replace(paramNamePlaceholder, paramName); + return (startingSlash ? `/${optional ? '?' : ''}` : '') + + (optional ? `(?:${regex})?` : regex); + }); + regexPattern = regexPattern.replace(restParamRegex, `(?.*)`); + return { + regex: new RegExp(`^${regexPattern}$`, routeInfo.caseSensitive ? undefined : 'i'), + and: routeInfo.and, + ignoreForFallback: !!routeInfo.ignoreForFallback + }; + } + /** + * Tests the route defined by the provided route information against the current URL to determine if it matches. + * @param routeMatchInfo Route information used for route matching. + * @returns A tuple containing the match result (a Boolean value) and any route parameters obtained. + */ + testRoute(routeMatchInfo: { regex?: RegExp; and?: AndUntyped; }) { + const matches = routeMatchInfo.regex ? routeMatchInfo.regex.exec(this.testPath) : null; + const routeParams = matches?.groups ? { ...matches.groups } as RouteStatus['routeParams'] : undefined; + if (routeParams) { + for (let key in routeParams) { + if (routeParams[key] === undefined) { + delete routeParams[key]; + continue; + } + routeParams[key] = tryParseValue(decodeURIComponent(routeParams[key] as string)); + } + } + const match = (!!matches || !routeMatchInfo.regex) && (!routeMatchInfo.and || routeMatchInfo.and(routeParams)); + return [match, routeParams] as const; + } +} diff --git a/src/lib/kernel/RouterEngine.svelte.ts b/src/lib/kernel/RouterEngine.svelte.ts index 883b64d..82f43bb 100644 --- a/src/lib/kernel/RouterEngine.svelte.ts +++ b/src/lib/kernel/RouterEngine.svelte.ts @@ -1,9 +1,10 @@ -import type { AndUntyped, Hash, PatternRouteInfo, RegexRouteInfo, RouteInfo, RouteStatus } from "../types.js"; +import type { AndUntyped, Hash, RegexRouteInfo, RouteInfo, RouteStatus } from "../types.js"; import { traceOptions, registerRouter, unregisterRouter } from "./trace.svelte.js"; import { location } from "./Location.js"; import { routingOptions } from "./options.js"; import { resolveHashValue } from "./resolveHashValue.js"; import { assertAllowedRoutingMode } from "$lib/utils.js"; +import { joinPaths, RouteHelper } from "./RouteHelper.svelte.js"; /** * RouterEngine's options. @@ -30,63 +31,10 @@ function isRouterEngine(obj: unknown): obj is RouterEngine { return obj instanceof RouterEngine; } -/** - * Joins the provided paths into a single path. - * @param paths Paths to join. - * @returns The joined path. - */ -export function joinPaths(...paths: string[]) { - const result = paths.reduce((acc, path, index) => { - const trimmedPath = (path ?? '').replace(/^\/|\/$/g, ''); - return acc + (index > 0 && !acc.endsWith('/') && trimmedPath.length > 0 ? '/' : '') + trimmedPath; - }, hasLeadingSlash(paths) ? '/' : ''); - return noTrailingSlash(result); -} - -function hasLeadingSlash(paths: (string | undefined)[]) { - for (let path of paths) { - if (!path) { - continue; - } - return path.startsWith('/'); - } - return false; -} - -function noTrailingSlash(path: string) { - return path !== '/' && path.endsWith('/') ? path.slice(0, -1) : path; -} - function routeInfoIsRegexInfo(info: unknown): info is RegexRouteInfo { return (info as RegexRouteInfo).regex instanceof RegExp; } -function escapeRegExp(string: string): string { - return string.replace(/[.+^${}()|[\]\\]/g, '\\$&'); -} - -function tryParseValue(value: string) { - if (value === '' || value === undefined || value === null) { - return value; - } - const num = Number(value); - if (!isNaN(num)) { - return num; - } - if (value === 'true') { - return true; - } - if (value === 'false') { - return false; - } - return value; -} - -const identifierRegex = /(\/)?:([a-zA-Z_]\w*)(\?)?/g; -const paramNamePlaceholder = "paramName"; -const paramValueRegex = `(?<${paramNamePlaceholder}>[^/]+)`; -const restParamRegex = /\/\*$/; - /** * Internal key used to access the route patterns of a router engine. */ @@ -99,10 +47,10 @@ export const routePatternsKey = Symbol(); * `Route` components. */ export class RouterEngine { + #routeHelper; #cleanup = false; #parent: RouterEngine | undefined; #resolvedHash: Hash; - #hashId: string | undefined; /** * Gets or sets the router's identifier. This is displayed by the `RouterTracer` component. */ @@ -129,7 +77,7 @@ export class RouterEngine { map.set( key, routeInfoIsRegexInfo(route) ? { regex: route.regex, and: route.and, ignoreForFallback: !!route.ignoreForFallback } : - this.#parseRoutePattern(route) + this.#routeHelper.parseRoutePattern(route, this.basePath) ); return map; }, new Map())); @@ -138,25 +86,18 @@ export class RouterEngine { return this.#routePatterns; } - testPath = $derived.by(() => noTrailingSlash(this.#hashId ? (location.hashPaths[this.#hashId] || '/') : this.path)); + /** + * Gets the test path this router engine uses to test route paths. Its value depends on the router's routing mode + * (universe). + */ + readonly testPath = $derived.by(() => this.#routeHelper.testPath); #routeStatusData = $derived.by(() => { const routeStatus = {} as Record; let noMatches = true; for (let routeKey of Object.keys(this.routes)) { const pattern = this.#routePatterns.get(routeKey)!; - const matches = pattern.regex ? pattern.regex.exec(this.testPath) : null; - const routeParams = matches?.groups ? { ...matches.groups } as RouteStatus['routeParams'] : undefined; - if (routeParams) { - for (let key in routeParams) { - if (routeParams[key] === undefined) { - delete routeParams[key]; - continue; - } - routeParams[key] = tryParseValue(decodeURIComponent(routeParams[key] as string)); - } - } - const match = (!!matches || !pattern.regex) && (!pattern.and || pattern.and(routeParams)); + const [match, routeParams] = this.#routeHelper.testRoute(pattern); noMatches = noMatches && (pattern.ignoreForFallback ? true : !match); routeStatus[routeKey] = { match, @@ -175,32 +116,6 @@ export class RouterEngine { * patterns. */ noMatches = $derived(this.#routeStatusData[1]); - /** - * Parses the string pattern in the provided route information object into a regular expression. - * @param routeInfo Pattern route information to parse. - * @returns An object with the regular expression and the optional predicate function. - */ - #parseRoutePattern(routeInfo: PatternRouteInfo): { regex?: RegExp; and?: AndUntyped; ignoreForFallback: boolean; } { - if (!routeInfo.pattern) { - return { - and: routeInfo.and, - ignoreForFallback: !!routeInfo.ignoreForFallback - } - } - const fullPattern = joinPaths(this.basePath, routeInfo.pattern === '/' ? '' : routeInfo.pattern); - const escapedPattern = escapeRegExp(fullPattern); - let regexPattern = escapedPattern.replace(identifierRegex, (_match, startingSlash, paramName, optional, offset) => { - let regex = paramValueRegex.replace(paramNamePlaceholder, paramName); - return (startingSlash ? `/${optional ? '?' : ''}` : '') - + (optional ? `(?:${regex})?` : regex); - }); - regexPattern = regexPattern.replace(restParamRegex, `(?.*)`); - return { - regex: new RegExp(`^${regexPattern}$`, routeInfo.caseSensitive ? undefined : 'i'), - and: routeInfo.and, - ignoreForFallback: !!routeInfo.ignoreForFallback - }; - } /** * Initializes a new instance of this class with the specified options. */ @@ -229,11 +144,9 @@ export class RouterEngine { if (routingOptions.hashMode !== 'multi' && typeof this.#resolvedHash === 'string') { throw new Error("A hash path ID was given, but is only allowed when the library's hash mode has been set to 'multi'."); } - this.#hashId = typeof this.#resolvedHash === 'string' ? - this.#resolvedHash : - (this.#resolvedHash ? 'single' : undefined); } assertAllowedRoutingMode(this.#resolvedHash); + this.#routeHelper = new RouteHelper(this.#resolvedHash); if (traceOptions.routerHierarchy) { registerRouter(this); this.#cleanup = true; @@ -247,16 +160,6 @@ export class RouterEngine { get url() { return location.url; } - /** - * Gets the environment's current path. - * - * This is a sanitized version of `location.url.pathname` that strips out drive letters for the case of Electron in - * Windows. It is highly recommended to always use this path whenever possible. - */ - get path() { - const hasDriveLetter = this.url.protocol.startsWith('file:') && this.url.pathname[2] === ':'; - return hasDriveLetter ? this.url.pathname.substring(3) : this.url.pathname; - } /** * Gets the browser's current state. * diff --git a/src/lib/kernel/buildHref.test.ts b/src/lib/kernel/buildHref.test.ts new file mode 100644 index 0000000..5c0d939 --- /dev/null +++ b/src/lib/kernel/buildHref.test.ts @@ -0,0 +1,310 @@ +import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { buildHref } from './buildHref.js'; +import { init } from '../init.js'; +import { location } from './Location.js'; + +describe('buildHref', () => { + let cleanup: Function; + beforeAll(() => { + cleanup = init(); + }); + afterAll(() => { + cleanup(); + }); + + beforeEach(() => { + // Reset to a clean base URL for each test + location.url.href = 'https://example.com/current?currentParam=value'; + }); + + describe('Basic functionality', () => { + test('Should combine path from first HREF and hash from second HREF.', () => { + const pathPiece = 'https://example.com/new-path'; + const hashPiece = 'https://example.com/any-path#new-hash'; + + const result = buildHref(pathPiece, hashPiece); + + expect(result).toBe('/new-path#new-hash'); + }); + + test('Should handle relative URLs correctly.', () => { + const pathPiece = '/relative-path'; + const hashPiece = '/any-path#relative-hash'; + + const result = buildHref(pathPiece, hashPiece); + + expect(result).toBe('/relative-path#relative-hash'); + }); + + test('Should work when pathPiece has no path component.', () => { + const pathPiece = 'https://example.com/'; + const hashPiece = 'https://example.com/#hash-only'; + + const result = buildHref(pathPiece, hashPiece); + + expect(result).toBe('/#hash-only'); + }); + + test('Should work when hashPiece has no hash component.', () => { + const pathPiece = 'https://example.com/path-only'; + const hashPiece = 'https://example.com/any-path'; + + const result = buildHref(pathPiece, hashPiece); + + expect(result).toBe('/path-only'); + }); + + test('Should handle empty hash correctly.', () => { + const pathPiece = 'https://example.com/path'; + const hashPiece = 'https://example.com/any-path#'; + + const result = buildHref(pathPiece, hashPiece); + + expect(result).toBe('/path'); + }); + }); + + describe('Query parameter merging', () => { + test('Should merge query parameters from both pieces.', () => { + const pathPiece = 'https://example.com/path?pathParam=pathValue'; + const hashPiece = 'https://example.com/any-path?hashParam=hashValue#hash'; + + const result = buildHref(pathPiece, hashPiece); + + expect(result).toBe('/path?pathParam=pathValue&hashParam=hashValue#hash'); + }); + + test('Should handle query parameters in pathPiece only.', () => { + const pathPiece = 'https://example.com/path?onlyPath=value'; + const hashPiece = 'https://example.com/any-path#hash'; + + const result = buildHref(pathPiece, hashPiece); + + expect(result).toBe('/path?onlyPath=value#hash'); + }); + + test('Should handle query parameters in hashPiece only.', () => { + const pathPiece = 'https://example.com/path'; + const hashPiece = 'https://example.com/any-path?onlyHash=value#hash'; + + const result = buildHref(pathPiece, hashPiece); + + expect(result).toBe('/path?onlyHash=value#hash'); + }); + + test('Should handle duplicate parameter names by keeping both values.', () => { + const pathPiece = 'https://example.com/path?shared=pathValue'; + const hashPiece = 'https://example.com/any-path?shared=hashValue#hash'; + + const result = buildHref(pathPiece, hashPiece); + + expect(result).toBe('/path?shared=pathValue&shared=hashValue#hash'); + }); + + test('Should handle multiple parameters in both pieces.', () => { + const pathPiece = 'https://example.com/path?param1=value1¶m2=value2'; + const hashPiece = 'https://example.com/any-path?param3=value3¶m4=value4#hash'; + + const result = buildHref(pathPiece, hashPiece); + + expect(result).toBe('/path?param1=value1¶m2=value2¶m3=value3¶m4=value4#hash'); + }); + + test('Should work with empty query strings.', () => { + const pathPiece = 'https://example.com/path?'; + const hashPiece = 'https://example.com/any-path?#hash'; + + const result = buildHref(pathPiece, hashPiece); + + expect(result).toBe('/path#hash'); + }); + }); + + describe('preserveQuery option', () => { + beforeEach(() => { + // Set up current URL with query parameters to preserve + location.url.href = 'https://example.com/current?preserve1=value1&preserve2=value2&preserve3=value3'; + }); + + test('Should preserve all current query parameters when preserveQuery is true.', () => { + const pathPiece = 'https://example.com/path?new=param'; + const hashPiece = 'https://example.com/any-path#hash'; + + const result = buildHref(pathPiece, hashPiece, { preserveQuery: true }); + + expect(result).toBe('/path?new=param&preserve1=value1&preserve2=value2&preserve3=value3#hash'); + }); + + test('Should preserve specific query parameter when preserveQuery is a string.', () => { + const pathPiece = 'https://example.com/path'; + const hashPiece = 'https://example.com/any-path#hash'; + + const result = buildHref(pathPiece, hashPiece, { preserveQuery: 'preserve2' }); + + expect(result).toBe('/path?preserve2=value2#hash'); + }); + + test('Should preserve specific query parameters when preserveQuery is an array.', () => { + const pathPiece = 'https://example.com/path'; + const hashPiece = 'https://example.com/any-path#hash'; + + const result = buildHref(pathPiece, hashPiece, { preserveQuery: ['preserve1', 'preserve3'] }); + + expect(result).toBe('/path?preserve1=value1&preserve3=value3#hash'); + }); + + test('Should not preserve any parameters when preserveQuery is false.', () => { + const pathPiece = 'https://example.com/path?new=param'; + const hashPiece = 'https://example.com/any-path#hash'; + + const result = buildHref(pathPiece, hashPiece, { preserveQuery: false }); + + expect(result).toBe('/path?new=param#hash'); + }); + + test('Should not preserve any parameters when preserveQuery is not specified.', () => { + const pathPiece = 'https://example.com/path?new=param'; + const hashPiece = 'https://example.com/any-path#hash'; + + const result = buildHref(pathPiece, hashPiece); + + expect(result).toBe('/path?new=param#hash'); + }); + + test('Should handle preserveQuery with existing merged parameters.', () => { + const pathPiece = 'https://example.com/path?fromPath=pathVal'; + const hashPiece = 'https://example.com/any-path?fromHash=hashVal#hash'; + + const result = buildHref(pathPiece, hashPiece, { preserveQuery: 'preserve2' }); + + expect(result).toBe('/path?fromPath=pathVal&fromHash=hashVal&preserve2=value2#hash'); + }); + + test('Should handle non-existent preserve parameter gracefully.', () => { + const pathPiece = 'https://example.com/path'; + const hashPiece = 'https://example.com/any-path#hash'; + + const result = buildHref(pathPiece, hashPiece, { preserveQuery: 'nonExistent' }); + + expect(result).toBe('/path#hash'); + }); + }); + + describe('Edge cases', () => { + test('Should handle both pieces being the same URL.', () => { + const sameUrl = 'https://example.com/same?param=value#hash'; + + const result = buildHref(sameUrl, sameUrl); + + expect(result).toBe('/same?param=value¶m=value#hash'); + }); + + test('Should handle URLs with different domains.', () => { + const pathPiece = 'https://other-domain.com/path?param=value'; + const hashPiece = 'https://another-domain.com/any-path#hash'; + + const result = buildHref(pathPiece, hashPiece); + + expect(result).toBe('/path?param=value#hash'); + }); + + test('Should handle URLs with special characters in parameters.', () => { + const pathPiece = 'https://example.com/path?special=hello%20world'; + const hashPiece = 'https://example.com/any-path?encoded=test%2Bvalue#hash%20with%20spaces'; + + const result = buildHref(pathPiece, hashPiece); + + expect(result).toBe('/path?special=hello+world&encoded=test%2Bvalue#hash%20with%20spaces'); + }); + + test('Should handle root paths correctly.', () => { + const pathPiece = 'https://example.com/'; + const hashPiece = 'https://example.com/#root-hash'; + + const result = buildHref(pathPiece, hashPiece); + + expect(result).toBe('/#root-hash'); + }); + + test('Should handle complex hash fragments.', () => { + const pathPiece = 'https://example.com/path'; + const hashPiece = 'https://example.com/any-path#/complex/hash/route?hashParam=value'; + + const result = buildHref(pathPiece, hashPiece); + + expect(result).toBe('/path#/complex/hash/route?hashParam=value'); + }); + }); + + describe('Cross-universe redirection use case', () => { + test('Should support typical cross-universe redirection scenario.', () => { + // Simulate getting path piece from path router and hash piece from hash router + const pathUniverseHref = 'https://example.com/users/profile?pathParam=value'; + const hashUniverseHref = 'https://example.com/current#/dashboard/settings?hashParam=value'; + + const result = buildHref(pathUniverseHref, hashUniverseHref); + + expect(result).toBe('/users/profile?pathParam=value#/dashboard/settings?hashParam=value'); + }); + + test('Should handle preserving current query in cross-universe scenario.', () => { + location.url.href = 'https://example.com/current?globalParam=global&session=active'; + + const pathUniverseHref = 'https://example.com/users/profile'; + const hashUniverseHref = 'https://example.com/current#/dashboard'; + + const result = buildHref(pathUniverseHref, hashUniverseHref, { preserveQuery: ['session'] }); + + expect(result).toBe('/users/profile?session=active#/dashboard'); + }); + }); + + describe('Additional edge cases', () => { + test('Should handle URL fragments with encoded characters.', () => { + const pathPiece = 'https://example.com/path'; + const hashPiece = 'https://example.com/any#%20encoded%20hash'; + + const result = buildHref(pathPiece, hashPiece); + + expect(result).toBe('/path#%20encoded%20hash'); + }); + + test('Should handle when both pieces have same domain but different protocols.', () => { + const pathPiece = 'http://example.com/path'; + const hashPiece = 'https://example.com/other#hash'; + + const result = buildHref(pathPiece, hashPiece); + + expect(result).toBe('/path#hash'); + }); + + test('Should handle query parameters with empty values.', () => { + const pathPiece = 'https://example.com/path?empty='; + const hashPiece = 'https://example.com/other?also=&blank=#hash'; + + const result = buildHref(pathPiece, hashPiece); + + expect(result).toBe('/path?empty=&also=&blank=#hash'); + }); + + test('Should handle preserveQuery with empty current URL query.', () => { + location.url.href = 'https://example.com/current'; // No query parameters + + const pathPiece = 'https://example.com/path?new=param'; + const hashPiece = 'https://example.com/other#hash'; + + const result = buildHref(pathPiece, hashPiece, { preserveQuery: true }); + + expect(result).toBe('/path?new=param#hash'); + }); + + test('Should handle complex multi-hash routing fragment.', () => { + const pathPiece = 'https://example.com/app'; + const hashPiece = 'https://example.com/other#main=/dashboard;sidebar=/menu'; + + const result = buildHref(pathPiece, hashPiece); + + expect(result).toBe('/app#main=/dashboard;sidebar=/menu'); + }); + }); +}); \ No newline at end of file diff --git a/src/lib/kernel/buildHref.ts b/src/lib/kernel/buildHref.ts new file mode 100644 index 0000000..76b54be --- /dev/null +++ b/src/lib/kernel/buildHref.ts @@ -0,0 +1,30 @@ +import type { BuildHrefOptions } from "$lib/types.js"; +import { location } from "./Location.js"; +import { mergeQueryParams } from "./preserveQuery.js"; + +/** + * Builds a new HREF by combining the path piece from one HREF and the hash piece from another. + * + * Any query parameters present in either piece are merged and included in the resulting HREF. Furthermore, if the + * `preserveQuery` option is provided, additional query parameters from the current URL are also merged in. + * + * ### When to Use + * + * This is a helper function that came to be when the redirection feature was added to the library. The specific use + * case is cross-routing-universe redirections, where the "source" universe's path is not changed by normal redireciton + * because "normal" **cross-universe redirections** don't alter other universes' paths. + * + * This function, in conjunction with the `calculateHref` function, allows relatively easy construction of the desired + * final HREF by combining the results of 2 `calculateHref` calls: One to get the path piece from the source universe, + * and another to get the hash piece for the other universe. + * @param pathPiece HREF value containing the desired path piece. + * @param hashPiece HREF value containing the desired hash piece. + * @param options Optional set of options. + * @returns The built HREF using the provided pieces. + */ +export function buildHref(pathPiece: string, hashPiece: string, options?: BuildHrefOptions): string { + const pp = new URL(pathPiece, location.url); + const hp = new URL(hashPiece, location.url); + let sp = mergeQueryParams(mergeQueryParams(pp.searchParams, hp.searchParams), options?.preserveQuery); + return `${pp.pathname}${sp?.size ? `?${sp.toString()}` : ''}${hp.hash}`; +} diff --git a/src/lib/kernel/calculateHref.test.ts b/src/lib/kernel/calculateHref.test.ts index a29f05a..4a6b46a 100644 --- a/src/lib/kernel/calculateHref.test.ts +++ b/src/lib/kernel/calculateHref.test.ts @@ -1,5 +1,6 @@ -import { describe, test, expect, beforeAll, afterAll, beforeEach } from "vitest"; -import { calculateHref, type CalculateHrefOptions } from "./calculateHref.js"; +import { describe, test, expect, beforeAll, afterAll, beforeEach, vi } from "vitest"; +import { calculateHref } from "./calculateHref.js"; +import * as calculateMultiHashHrefModule from "./calculateMultiHashFragment.js"; import { init } from "../init.js"; import { location } from "./Location.js"; import { ROUTING_UNIVERSES, ALL_HASHES } from "$test/test-utils.js"; @@ -164,30 +165,18 @@ describe("calculateHref", () => { }); if (universe.hashMode === 'multi') { - describe("Multi-hash routing behavior", () => { - test("Should preserve all existing paths when adding a new path", () => { + describe("Multi-hash routing integration", () => { + test("Should delegate to calculateMultiHashHref for multi-hash calculations", () => { // Arrange const newPath = "/sample/path"; - const newHashId = 'new'; + const hashId = universe.hash || universe.defaultHash; // Act - const href = calculateHref({ hash: newHashId }, newPath); + const href = calculateHref({ hash: hashId }, newPath); - // Assert - expect(href).toBe(`${baseHash};${newHashId}=${newPath}`); - }); - - test("Should preserve all existing paths when updating an existing path", () => { - // Arrange - const newPath = "/sample/path"; - const existingHashId = universe.hash || universe.defaultHash; // Use the universe's hash ID - const expected = baseHash.replace(new RegExp(`(${existingHashId}=)[^;]+`), `$1${newPath}`); - - // Act - const href = calculateHref({ hash: existingHashId }, newPath); - - // Assert - expect(href).toEqual(expected); + // Assert - Verify the result follows multi-hash format + expect(href).toMatch(/^#[^;]+=\/sample\/path/); + expect(href).toContain(';p2=path/two'); // Should preserve existing paths }); }); } @@ -274,4 +263,79 @@ describe("calculateHref", () => { expect(() => calculateHref("/http-endpoint", "/https-folder")).not.toThrow(); }); }); + + describe("Integration with calculateMultiHashHref", () => { + let cleanup: Function; + + beforeAll(() => { + cleanup = init({ hashMode: 'multi' }); + }); + + afterAll(() => { + cleanup(); + }); + + beforeEach(() => { + location.url.href = "https://example.com#p1=/existing/path;p2=/another/path"; + }); + + test("Should call calculateMultiHashHref when hash is a string (named hash routing)", () => { + // Arrange + const spy = vi.spyOn(calculateMultiHashHrefModule, 'calculateMultiHashFragment').mockReturnValue('p1=/new/path;p2=/another/path'); + const newPath = "/new/path"; + + // Act + const href = calculateHref({ hash: 'p1' }, newPath); + + // Assert + expect(spy).toHaveBeenCalledWith({ p1: newPath }); + expect(href).toBe('#p1=/new/path;p2=/another/path'); + + spy.mockRestore(); + }); + + test("Should not call calculateMultiHashHref when hash is false (path routing)", () => { + // Arrange + const spy = vi.spyOn(calculateMultiHashHrefModule, 'calculateMultiHashFragment'); + const newPath = "/new/path"; + + // Act + const href = calculateHref({ hash: false }, newPath); + + // Assert + expect(spy).not.toHaveBeenCalled(); + expect(href).toBe(newPath); + + spy.mockRestore(); + }); + + test("Should not call calculateMultiHashHref when hash is true (single hash routing)", () => { + // Arrange + const spy = vi.spyOn(calculateMultiHashHrefModule, 'calculateMultiHashFragment'); + const newPath = "/new/path"; + + // Act + const href = calculateHref({ hash: true }, newPath); + + // Assert + expect(spy).not.toHaveBeenCalled(); + expect(href).toBe('#/new/path'); + + spy.mockRestore(); + }); + + test("Should pass correct parameters to calculateMultiHashHref with joined paths", () => { + // Arrange + const spy = vi.spyOn(calculateMultiHashHrefModule, 'calculateMultiHashFragment').mockReturnValue('p1=/path1/path2;p2=/another/path'); + + // Act + const href = calculateHref({ hash: 'p1' }, '/path1', '/path2'); + + // Assert + expect(spy).toHaveBeenCalledWith({ p1: '/path1/path2' }); + expect(href).toBe('#p1=/path1/path2;p2=/another/path'); + + spy.mockRestore(); + }); + }); }); diff --git a/src/lib/kernel/calculateHref.ts b/src/lib/kernel/calculateHref.ts index 86b56f4..eab8b40 100644 --- a/src/lib/kernel/calculateHref.ts +++ b/src/lib/kernel/calculateHref.ts @@ -2,8 +2,9 @@ import type { Hash, PreserveQuery } from "../types.js"; import { dissectHrefs } from "./dissectHrefs.js"; import { location } from "./Location.js"; import { mergeQueryParams } from "./preserveQuery.js"; -import { joinPaths } from "./RouterEngine.svelte.js"; +import { joinPaths } from "./RouteHelper.svelte.js"; import { resolveHashValue } from "./resolveHashValue.js"; +import { calculateMultiHashFragment } from "./calculateMultiHashFragment.js"; export type CalculateHrefOptions = { /** @@ -27,21 +28,7 @@ export type CalculateHrefOptions = { hash?: Hash; }; -function calculateMultiHashHref(hashId: string, newPath: string) { - let idExists = false; - let finalUrl = ''; - for (let [id, path] of Object.entries(location.hashPaths)) { - if (id === hashId) { - idExists = true; - path = newPath; - } - finalUrl += `;${id}=${path}`; - } - if (!idExists) { - finalUrl += `;${hashId}=${newPath}`; - } - return finalUrl.substring(1); -} + /** * Combines the given HREF's into a single HREF that also includes any query string parameters that are either carried @@ -99,7 +86,7 @@ export function calculateHref(...allArgs: (CalculateHrefOptions | string | undef } searchParams = mergeQueryParams(searchParams, preserveQuery); const path = typeof hash === 'string' ? - calculateMultiHashHref(hash, joinPaths(...dissected.paths)) : + calculateMultiHashFragment({ [hash]: joinPaths(...dissected.paths) }) : joinPaths(...dissected.paths); let hashValue = hash === false ? dissected.hashes.find(h => h.length) || (preserveHash ? location.url.hash.substring(1) : '') : diff --git a/src/lib/kernel/calculateMultiHashFragment.test.ts b/src/lib/kernel/calculateMultiHashFragment.test.ts new file mode 100644 index 0000000..156574d --- /dev/null +++ b/src/lib/kernel/calculateMultiHashFragment.test.ts @@ -0,0 +1,364 @@ +import { describe, test, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import { calculateMultiHashFragment } from "./calculateMultiHashFragment.js"; +import { init } from "../init.js"; +import { location } from "./Location.js"; + +describe("calculateMultiHashHref", () => { + let cleanup: Function; + + beforeAll(() => { + cleanup = init({ hashMode: 'multi' }); + }); + + afterAll(() => { + cleanup(); + }); + + describe("Basic functionality", () => { + beforeEach(() => { + // Reset location to a clean state with multiple hash paths + location.url.href = "https://example.com#p1=/initial/path;p2=/another/path;p3=/third/path"; + }); + + test("Should preserve existing paths not specified in input.", () => { + // Act + const result = calculateMultiHashFragment({ p1: "/new/path" }); + + // Assert + expect(result).toBe("p1=/new/path;p2=/another/path;p3=/third/path"); + }); + + test("Should update existing paths when specified in input.", () => { + // Act + const result = calculateMultiHashFragment({ + p2: "/updated/path", + p3: "/also/updated" + }); + + // Assert + expect(result).toBe("p1=/initial/path;p2=/updated/path;p3=/also/updated"); + }); + + test("Should add new hash paths not present in existing paths.", () => { + // Act + const result = calculateMultiHashFragment({ + p4: "/new/hash/path", + p5: "/another/new/path" + }); + + // Assert + expect(result).toBe("p1=/initial/path;p2=/another/path;p3=/third/path;p4=/new/hash/path;p5=/another/new/path"); + }); + + test("Should handle combination of preserving, updating, and adding paths.", () => { + // Act + const result = calculateMultiHashFragment({ + p2: "/updated/existing", + p4: "/brand/new", + p6: "/another/new" + }); + + // Assert + expect(result).toBe("p1=/initial/path;p2=/updated/existing;p3=/third/path;p4=/brand/new;p6=/another/new"); + }); + }); + + describe("Edge cases", () => { + test("Should handle empty input when existing paths are present.", () => { + // Arrange + location.url.href = "https://example.com#p1=/path/one;p2=/path/two"; + + // Act + const result = calculateMultiHashFragment({}); + + // Assert + expect(result).toBe("p1=/path/one;p2=/path/two"); + }); + + test("Should handle input when no existing paths are present.", () => { + // Arrange + location.url.href = "https://example.com"; + + // Act + const result = calculateMultiHashFragment({ + p1: "/first/path", + p2: "/second/path" + }); + + // Assert + expect(result).toBe("p1=/first/path;p2=/second/path"); + }); + + test("Should handle empty input with no existing paths.", () => { + // Arrange + location.url.href = "https://example.com"; + + // Act + const result = calculateMultiHashFragment({}); + + // Assert + expect(result).toBe(""); + }); + + test("Should handle single existing path being updated.", () => { + // Arrange + location.url.href = "https://example.com#p1=/original/path"; + + // Act + const result = calculateMultiHashFragment({ p1: "/updated/path" }); + + // Assert + expect(result).toBe("p1=/updated/path"); + }); + + test("Should handle single new path with no existing paths.", () => { + // Arrange + location.url.href = "https://example.com"; + + // Act + const result = calculateMultiHashFragment({ p1: "/new/path" }); + + // Assert + expect(result).toBe("p1=/new/path"); + }); + }); + + describe("Path value handling", () => { + beforeEach(() => { + location.url.href = "https://example.com#p1=/base/path"; + }); + + test("Should handle root paths correctly.", () => { + // Act + const result = calculateMultiHashFragment({ + p1: "/", + p2: "/" + }); + + // Assert + expect(result).toBe("p1=/;p2=/"); + }); + + test("Should remove paths when given empty path values.", () => { + // Act + const result = calculateMultiHashFragment({ + p1: "", + p2: "" + }); + + // Assert - Empty paths should be completely removed + expect(result).toBe(""); + }); + + test("Should handle complex nested paths.", () => { + // Act + const result = calculateMultiHashFragment({ + p1: "/users/123/profile/edit", + p2: "/admin/settings/permissions/advanced" + }); + + // Assert + expect(result).toBe("p1=/users/123/profile/edit;p2=/admin/settings/permissions/advanced"); + }); + + test("Should preserve path parameters and special characters.", () => { + // Act + const result = calculateMultiHashFragment({ + p1: "/api/users/:id", + p2: "/path/with-dashes/and_underscores" + }); + + // Assert + expect(result).toBe("p1=/api/users/:id;p2=/path/with-dashes/and_underscores"); + }); + }); + + describe("Empty string path removal", () => { + beforeEach(() => { + location.url.href = "https://example.com#p1=/existing/path;p2=/another/existing;p3=/third/existing"; + }); + + test("Should remove existing paths when updated with empty string.", () => { + // Act + const result = calculateMultiHashFragment({ p2: "" }); + + // Assert - p2 should be completely removed + expect(result).toBe("p1=/existing/path;p3=/third/existing"); + }); + + test("Should not add new paths when given empty string.", () => { + // Act + const result = calculateMultiHashFragment({ p4: "" }); + + // Assert - p4 should not be added at all + expect(result).toBe("p1=/existing/path;p2=/another/existing;p3=/third/existing"); + }); + + test("Should handle mix of valid updates and empty string removals.", () => { + // Act + const result = calculateMultiHashFragment({ + p1: "/updated/path", // Update existing + p2: "", // Remove existing + p4: "/new/path" // Add new valid + }); + + // Assert + expect(result).toBe("p1=/updated/path;p3=/third/existing;p4=/new/path"); + }); + + test("Should handle all existing paths being cleared with empty strings.", () => { + // Act + const result = calculateMultiHashFragment({ + p1: "", + p2: "", + p3: "" + }); + + // Assert - All paths removed, result should be empty + expect(result).toBe(""); + }); + + test("Should distinguish between empty string (removal) and valid root path.", () => { + // Arrange + location.url.href = "https://example.com#p1=/existing"; + + // Act + const result = calculateMultiHashFragment({ + p1: "/", // Valid root path + p2: "", // Empty string - should be skipped + p3: "/valid" // Valid path + }); + + // Assert - Only valid paths should be included + expect(result).toBe("p1=/;p3=/valid"); + }); + + test("Should preserve order when some paths are removed via empty strings.", () => { + // Arrange + location.url.href = "https://example.com#a=/path/a;b=/path/b;c=/path/c;d=/path/d;e=/path/e"; + + // Act - Remove alternating paths with empty strings + const result = calculateMultiHashFragment({ + a: "/updated/a", + b: "", // Remove + c: "/updated/c", + d: "", // Remove + f: "/new/f" + }); + + // Assert - Should maintain original order for kept paths, append new ones + expect(result).toBe("a=/updated/a;c=/updated/c;e=/path/e;f=/new/f"); + }); + + test("Should handle when only empty strings are provided for new paths.", () => { + // Arrange + location.url.href = "https://example.com#existing=/kept"; + + // Act + const result = calculateMultiHashFragment({ + new1: "", + new2: "", + new3: "" + }); + + // Assert - No new paths should be added, only existing preserved + expect(result).toBe("existing=/kept"); + }); + }); + + describe("Hash ID handling", () => { + beforeEach(() => { + location.url.href = "https://example.com#main=/main/path;secondary=/secondary/path"; + }); + + test("Should handle various hash ID formats.", () => { + // Act + const result = calculateMultiHashFragment({ + "main": "/updated/main", + "my-hash": "/new/dash", + "my_hash": "/new/underscore", + "hash123": "/numeric/suffix" + }); + + // Assert + expect(result).toBe("main=/updated/main;secondary=/secondary/path;my-hash=/new/dash;my_hash=/new/underscore;hash123=/numeric/suffix"); + }); + + test("Should handle single character hash IDs.", () => { + // Act + const result = calculateMultiHashFragment({ + "a": "/path/a", + "x": "/path/x", + "z": "/path/z" + }); + + // Assert + expect(result).toBe("main=/main/path;secondary=/secondary/path;a=/path/a;x=/path/x;z=/path/z"); + }); + }); + + describe("Order preservation", () => { + test("Should maintain existing path order and append new paths in input order.", () => { + // Arrange + location.url.href = "https://example.com#zebra=/z/path;alpha=/a/path;beta=/b/path"; + + // Act + const result = calculateMultiHashFragment({ + gamma: "/g/path", + alpha: "/updated/a/path", + delta: "/d/path" + }); + + // Assert + // Existing paths maintain their original order, new paths are appended in input order + expect(result).toBe("zebra=/z/path;alpha=/updated/a/path;beta=/b/path;gamma=/g/path;delta=/d/path"); + }); + + test("Should preserve order when only adding new paths.", () => { + // Arrange + location.url.href = "https://example.com#c=/c/path;a=/a/path;b=/b/path"; + + // Act + const result = calculateMultiHashFragment({ + z: "/z/path", + x: "/x/path", + y: "/y/path" + }); + + // Assert + expect(result).toBe("c=/c/path;a=/a/path;b=/b/path;z=/z/path;x=/x/path;y=/y/path"); + }); + }); + + describe("Cross-universe redirection use cases", () => { + test("Should support clearing a source universe path while setting target universe path.", () => { + // Arrange - simulate having both source and target universes + location.url.href = "https://example.com#source=/current/source/path;target=/current/target/path"; + + // Act - Clear source and redirect to new target + const result = calculateMultiHashFragment({ + source: "", // Clear the source universe with empty string + target: "/new/target/path" // Set new target path + }); + + // Assert - Source should be completely removed, not left as empty + expect(result).toBe("target=/new/target/path"); + }); + + test("Should support updating multiple universes simultaneously for complex redirection scenarios.", () => { + // Arrange + location.url.href = "https://example.com#main=/main/current;sidebar=/sidebar/current;modal=/modal/current"; + + // Act - Complex cross-universe update scenario + const result = calculateMultiHashFragment({ + main: "/main/redirected", + sidebar: "", // Clear sidebar with empty string + modal: "/modal/new", + notifications: "/notifications/init" // Add new universe + }); + + // Assert - Sidebar should be completely removed, not left as empty + expect(result).toBe("main=/main/redirected;modal=/modal/new;notifications=/notifications/init"); + }); + }); +}); diff --git a/src/lib/kernel/calculateMultiHashFragment.ts b/src/lib/kernel/calculateMultiHashFragment.ts new file mode 100644 index 0000000..542f3eb --- /dev/null +++ b/src/lib/kernel/calculateMultiHashFragment.ts @@ -0,0 +1,26 @@ +import { location } from "./Location.js"; + +/** + * Calculates a new hash fragment with the specified named hash paths while preserving any existing hash paths not + * specified. Paths set to empty string ("") will be completely removed from the hash fragment. + * @param hashPaths The hash paths to include (or remove via empty strings) in the final HREF. + * @returns The calculated hash fragment (without the leading `#`). + */ +export function calculateMultiHashFragment(hashPaths: Record) { + const existingIds = new Set(); + let finalUrl = ''; + for (let [id, path] of Object.entries(location.hashPaths)) { + existingIds.add(id); + path = hashPaths[id] ?? path; + if (path) { + finalUrl += `;${id}=${path}`; + } + } + for (let [hashId, newPath] of Object.entries(hashPaths)) { + if (existingIds.has(hashId) || !newPath) { + continue; + } + finalUrl += `;${hashId}=${newPath}`; + } + return finalUrl.substring(1); +} diff --git a/src/lib/kernel/index.test.ts b/src/lib/kernel/index.test.ts index 0b2d41f..5a8dd36 100644 --- a/src/lib/kernel/index.test.ts +++ b/src/lib/kernel/index.test.ts @@ -17,6 +17,8 @@ describe('index', () => { 'LocationLite', 'LocationFull', 'preserveQueryInUrl', + 'buildHref', + 'calculateMultiHashFragment' ]; // Act. diff --git a/src/lib/kernel/index.ts b/src/lib/kernel/index.ts index 27e9744..a0b69b6 100644 --- a/src/lib/kernel/index.ts +++ b/src/lib/kernel/index.ts @@ -1,7 +1,9 @@ export { location } from "./Location.js"; -export { RouterEngine, joinPaths } from "./RouterEngine.svelte.js"; +export { RouterEngine } from "./RouterEngine.svelte.js"; +export { joinPaths } from "./RouteHelper.svelte.js"; export { isConformantState } from "./isConformantState.js"; export { calculateHref } from "./calculateHref.js"; +export { calculateMultiHashFragment } from "./calculateMultiHashFragment.js"; export { calculateState } from "./calculateState.js"; export { initCore } from "./initCore.js"; export { LocationState } from "./LocationState.svelte.js"; @@ -10,3 +12,4 @@ export { InterceptedHistoryApi } from "./InterceptedHistoryApi.svelte.js"; export { LocationLite } from "./LocationLite.svelte.js"; export { LocationFull } from "./LocationFull.js"; export { preserveQueryInUrl } from "./preserveQuery.js"; +export { buildHref } from "./buildHref.js"; diff --git a/src/lib/kernel/preserveQuery.test.ts b/src/lib/kernel/preserveQuery.test.ts index fa1c1c4..d2c081c 100644 --- a/src/lib/kernel/preserveQuery.test.ts +++ b/src/lib/kernel/preserveQuery.test.ts @@ -72,4 +72,219 @@ describe('preserveQuery utilities', () => { expect(result?.get('another')).toBe('param'); }); }); + + describe('mergeQueryParams - Two URLSearchParams overload', () => { + test('Should merge two non-empty URLSearchParams correctly.', () => { + const set1 = new URLSearchParams('param1=value1¶m2=value2'); + const set2 = new URLSearchParams('param3=value3¶m4=value4'); + + const result = mergeQueryParams(set1, set2); + + expect(result?.get('param1')).toBe('value1'); + expect(result?.get('param2')).toBe('value2'); + expect(result?.get('param3')).toBe('value3'); + expect(result?.get('param4')).toBe('value4'); + }); + + test('Should handle duplicate parameter names by keeping both values.', () => { + const set1 = new URLSearchParams('shared=first&unique1=value1'); + const set2 = new URLSearchParams('shared=second&unique2=value2'); + + const result = mergeQueryParams(set1, set2); + + expect(result?.getAll('shared')).toEqual(['first', 'second']); + expect(result?.get('unique1')).toBe('value1'); + expect(result?.get('unique2')).toBe('value2'); + }); + + test('Should return set1 when set2 is undefined.', () => { + const set1 = new URLSearchParams('param=value'); + + const result = mergeQueryParams(set1, undefined); + + expect(result).toBe(set1); + }); + + test('Should return set1 when set2 is empty.', () => { + const set1 = new URLSearchParams('param=value'); + const set2 = new URLSearchParams(); + + const result = mergeQueryParams(set1, set2); + + expect(result).toBe(set1); + }); + + test('Should return set2 when set1 is undefined and set2 has parameters.', () => { + const set2 = new URLSearchParams('param=value'); + + const result = mergeQueryParams(undefined, set2); + + expect(result).toBe(set2); + }); + + test('Should return undefined when both sets are undefined.', () => { + const result = mergeQueryParams(undefined, undefined); + + expect(result).toBeUndefined(); + }); + + test('Should return undefined when set1 is undefined and set2 is empty.', () => { + const set2 = new URLSearchParams(); + + const result = mergeQueryParams(undefined, set2); + + expect(result).toBeUndefined(); + }); + + test('Should return set1 when both sets are empty.', () => { + const set1 = new URLSearchParams(); + const set2 = new URLSearchParams(); + + const result = mergeQueryParams(set1, set2); + + expect(result).toBe(set1); + }); + + test('Should handle parameters with empty values.', () => { + const set1 = new URLSearchParams('empty1=&normal=value'); + const set2 = new URLSearchParams('empty2=&another=test'); + + const result = mergeQueryParams(set1, set2); + + expect(result?.get('empty1')).toBe(''); + expect(result?.get('empty2')).toBe(''); + expect(result?.get('normal')).toBe('value'); + expect(result?.get('another')).toBe('test'); + }); + + test('Should handle parameters with special characters.', () => { + const set1 = new URLSearchParams('special=hello%20world&plus=test+value'); + const set2 = new URLSearchParams('encoded=user%40example.com&symbols=%21%40%23'); + + const result = mergeQueryParams(set1, set2); + + expect(result?.get('special')).toBe('hello world'); + expect(result?.get('plus')).toBe('test value'); + expect(result?.get('encoded')).toBe('user@example.com'); + expect(result?.get('symbols')).toBe('!@#'); + }); + + test('Should handle multiple values for the same parameter name.', () => { + const set1 = new URLSearchParams(); + set1.append('multi', 'value1'); + set1.append('multi', 'value2'); + + const set2 = new URLSearchParams(); + set2.append('multi', 'value3'); + set2.append('other', 'single'); + + const result = mergeQueryParams(set1, set2); + + expect(result?.getAll('multi')).toEqual(['value1', 'value2', 'value3']); + expect(result?.get('other')).toBe('single'); + }); + + test('Should preserve parameter order when merging.', () => { + const set1 = new URLSearchParams('a=1&b=2'); + const set2 = new URLSearchParams('c=3&d=4'); + + const result = mergeQueryParams(set1, set2); + + const entries = Array.from(result?.entries() || []); + expect(entries).toEqual([ + ['a', '1'], + ['b', '2'], + ['c', '3'], + ['d', '4'] + ]); + }); + + test('Should handle complex real-world scenario.', () => { + // Simulate path router parameters + const pathParams = new URLSearchParams('userId=123&action=edit'); + + // Simulate hash router parameters + const hashParams = new URLSearchParams('tab=settings&mode=advanced&userId=456'); + + const result = mergeQueryParams(pathParams, hashParams); + + expect(result?.getAll('userId')).toEqual(['123', '456']); + expect(result?.get('action')).toBe('edit'); + expect(result?.get('tab')).toBe('settings'); + expect(result?.get('mode')).toBe('advanced'); + }); + + test('Should return set1 with set2 parameters appended when merging occurs.', () => { + const set1 = new URLSearchParams('param1=value1'); + const set2 = new URLSearchParams('param2=value2'); + + const result = mergeQueryParams(set1, set2); + + // Function returns set1 (performance optimization) with set2 params appended + expect(result).toBe(set1); + expect(result?.get('param1')).toBe('value1'); + expect(result?.get('param2')).toBe('value2'); + }); + + test('Should handle edge case with only set2 having parameters when set1 is empty.', () => { + const set1 = new URLSearchParams(); // Empty + const set2 = new URLSearchParams('onlyInSet2=value'); + + const result = mergeQueryParams(set1, set2); + + // Function returns set1 (performance optimization) with set2 params appended + expect(result).toBe(set1); + expect(result?.get('onlyInSet2')).toBe('value'); + }); + + test('Should verify performance optimization - returns original objects when possible.', () => { + // Test case 1: Returns set2 when set1 is undefined + const set2Only = new URLSearchParams('param=value'); + const result1 = mergeQueryParams(undefined, set2Only); + expect(result1).toBe(set2Only); + + // Test case 2: Returns set1 when set2 is empty + const set1Only = new URLSearchParams('param=value'); + const emptySet = new URLSearchParams(); + const result2 = mergeQueryParams(set1Only, emptySet); + expect(result2).toBe(set1Only); + + // Test case 3: Returns set1 when both have parameters (modifies set1 in-place) + const set1Modified = new URLSearchParams('existing=value'); + const set2ToMerge = new URLSearchParams('new=param'); + const result3 = mergeQueryParams(set1Modified, set2ToMerge); + expect(result3).toBe(set1Modified); + expect(set1Modified.get('existing')).toBe('value'); // Original param + expect(set1Modified.get('new')).toBe('param'); // Merged param + }); + + test('Should create new URLSearchParams only when set1 is undefined and set2 has parameters.', () => { + const set2 = new URLSearchParams('param=value'); + + // This is the only case where a truly new instance is created + const result = mergeQueryParams(undefined, set2); + + // Actually, this returns set2 directly for performance, so this test documents that behavior + expect(result).toBe(set2); + }); + + test('Should not modify set2 when merging into set1.', () => { + const set1 = new URLSearchParams('original1=value1'); + const set2 = new URLSearchParams('original2=value2'); + + // Store original values to verify they don't change + const originalSet2String = set2.toString(); + + const result = mergeQueryParams(set1, set2); + + // set1 should be modified (it's the return value) + expect(result).toBe(set1); + expect(result?.get('original1')).toBe('value1'); + expect(result?.get('original2')).toBe('value2'); + + // set2 should remain unchanged + expect(set2.toString()).toBe(originalSet2String); + expect(set2.get('original1')).toBeNull(); // Should not have set1's params + }); + }); }); diff --git a/src/lib/kernel/preserveQuery.ts b/src/lib/kernel/preserveQuery.ts index 9bd2c9a..0929cab 100644 --- a/src/lib/kernel/preserveQuery.ts +++ b/src/lib/kernel/preserveQuery.ts @@ -13,6 +13,12 @@ export function preserveQueryInUrl(url: string, preserveQuery: PreserveQuery): s return urlObj.toString(); } +/** + * Internal helper to merge query parameters from 2 URL's into a third. + * @param set1: First set of query parameters. + * @param set2: Second set of query parameters. + */ +export function mergeQueryParams(set1: URLSearchParams | undefined, set2: URLSearchParams | undefined): URLSearchParams | undefined; /** * Internal helper to merge query parameters for calculateHref. * This handles the URLSearchParams merging logic without URL reconstruction. @@ -20,19 +26,22 @@ export function preserveQueryInUrl(url: string, preserveQuery: PreserveQuery): s * @param preserveQuery The query preservation options. * @returns The merged URLSearchParams or undefined if no merging is needed. */ -export function mergeQueryParams(existingParams: URLSearchParams | undefined, preserveQuery: PreserveQuery): URLSearchParams | undefined { - if (!preserveQuery || !location.url.searchParams.size) { - return existingParams; +export function mergeQueryParams(existingParams: URLSearchParams | undefined, preserveQuery?: PreserveQuery): URLSearchParams | undefined; +export function mergeQueryParams(set1: URLSearchParams | undefined, pqOrSet2: PreserveQuery | URLSearchParams | undefined): URLSearchParams | undefined { + const set2 = pqOrSet2 instanceof URLSearchParams ? pqOrSet2 : location.url.searchParams; + const preserveQuery = pqOrSet2 instanceof URLSearchParams ? true : pqOrSet2; + if (!pqOrSet2 || !set2.size) { + return set1; } - if (!existingParams && preserveQuery === true) { - return location.url.searchParams; + if (!set1 && preserveQuery === true) { + return set2; } - const mergedParams = existingParams ?? new URLSearchParams(); + const mergedParams = set1 ?? new URLSearchParams(); const transferValue = (key: string) => { - const values = location.url.searchParams.getAll(key); + const values = set2.getAll(key); if (values.length) { values.forEach((v) => mergedParams.append(key, v)); } @@ -41,7 +50,7 @@ export function mergeQueryParams(existingParams: URLSearchParams | undefined, pr if (typeof preserveQuery === 'string') { transferValue(preserveQuery); } else { - for (let key of (Array.isArray(preserveQuery) ? preserveQuery : location.url.searchParams.keys())) { + for (let key of (Array.isArray(preserveQuery) ? preserveQuery : set2.keys())) { transferValue(key); } } diff --git a/src/lib/testing/test-utils.ts b/src/lib/testing/test-utils.ts index 4473e57..b4580aa 100644 --- a/src/lib/testing/test-utils.ts +++ b/src/lib/testing/test-utils.ts @@ -24,7 +24,7 @@ export type RoutingUniverse = { /** * Short universe identifier. Used in test titles and descriptions. */ - text: string; + text: 'IPR' | 'PR' | 'IHR' | 'HR' | 'IMHR' | 'MHR'; /** * Descriptive universe name. More of a document-by-code property. Not commonly used as it makes text very long. */ diff --git a/src/lib/testing/testWithEffect.svelte.ts b/src/lib/testing/testWithEffect.svelte.ts new file mode 100644 index 0000000..ff813e3 --- /dev/null +++ b/src/lib/testing/testWithEffect.svelte.ts @@ -0,0 +1,16 @@ +import { test } from "vitest"; + +export function testWithEffect(name: string, fn: () => void | Promise) { + test(name, () => { + let promise!: void | Promise; + const cleanup = $effect.root(() => { + promise = fn(); + }); + if (promise) { + return promise.finally(cleanup); + } + else { + cleanup(); + } + }); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index abeb0c4..6bde376 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -101,6 +101,40 @@ export type PatternRouteInfo = CoreRouteInfo & { */ export type RouteInfo = RegexRouteInfo | PatternRouteInfo; +/** + * Distributes the Omit over unions. + */ +type NoIgnoreForFallback = T extends any ? Omit : never; + +/** + * Defines the shape of redirection information used by the Redirector class. + */ +export type RedirectedRouteInfo = NoIgnoreForFallback & { + /** + * The HREF to navigate to (via `location.navigate()` or `location.goTo()`). It can be a string or a function that + * receives the matched route parameters and returns a string. + */ + href: string | ((routeParams: Record | undefined) => string); +} & ({ + /** + * Indicates that the redirection should use the `Location.goTo` method. + */ + goTo: true; + /** + * Options for the `Location.goTo` method. + */ + options?: GoToOptions; +} | { + /** + * Indicates that the redirection should use the `Location.goTo` method. + */ + goTo?: false; + /** + * Options for the `Location.navigate` method. + */ + options?: NavigateOptions; +}); + /** * Defines the options that can be used when calling `Location.goTo`. */ @@ -149,6 +183,11 @@ export type NavigateOptions = Omit & { preserveHash?: boolean; }); +/** + * Defines the options for the `buildHref` function. + */ +export type BuildHrefOptions = Pick; + /** * Defines the capabilities of the location object, central for all routing functionality. */ @@ -157,6 +196,12 @@ export interface Location { * Gets a reactive URL object with the current window's URL. */ readonly url: URL; + /** + * Gets the environment's current path, "sanitized" for the cases of `file:` URL's in Windows. + * + * It is highly recommended to always use this path instead of `Location.url.pathname` whenever possible. + */ + readonly path: string; /** * Gets the current hash path or paths, depending on how the library was initialized. * From 5378de00240e7ddcde6decc73576f68bdb5a57b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ramirez=20Vargas=2C=20Jos=C3=A9=20Pablo?= Date: Sun, 23 Nov 2025 15:08:23 -0600 Subject: [PATCH 2/5] chore(docs): Improve JsDoc's for mergeQueryParams --- src/lib/kernel/preserveQuery.ts | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/lib/kernel/preserveQuery.ts b/src/lib/kernel/preserveQuery.ts index 0929cab..fcbc916 100644 --- a/src/lib/kernel/preserveQuery.ts +++ b/src/lib/kernel/preserveQuery.ts @@ -14,17 +14,35 @@ export function preserveQueryInUrl(url: string, preserveQuery: PreserveQuery): s } /** - * Internal helper to merge query parameters from 2 URL's into a third. + * Helper that merges query parameters from 2 URL's together. + * + * ### Important Notes + * + * + To preserve system resources, `set1` is modified directly to contain the merged results. + * + If the provided `set1` is `undefined` and all query parameters are to be preserved, then `set2` will be returned + * directly. + * + If `set1` is `undefined`, a new `URLSearchParams` will be created (and returned) to contain the merged results. + * + The return value will be `undefined` whenever `set1` is `undefined` and `set2` is also `undefined` or empty. * @param set1: First set of query parameters. * @param set2: Second set of query parameters. + * @returns The merged `URLSearchParams`, or `undefined`. */ export function mergeQueryParams(set1: URLSearchParams | undefined, set2: URLSearchParams | undefined): URLSearchParams | undefined; /** - * Internal helper to merge query parameters for calculateHref. - * This handles the URLSearchParams merging logic without URL reconstruction. - * @param existingParams Existing URLSearchParams from the new URL. + * Helper that merges the given search parameters with the ones found in the current environment's URL. + * + * ### Important Notes + * + * + To preserve system resources, `existingParams` is modified directly to contain the merged results. + * + The `URLSearchParams` from the global `location` object will be returned when all query parameters are preserved + * and `existingParams` is `undefined`. + * + If `existingParams` is `undefined`, a new `URLSearchParams` will be created (and returned) to contain the merged + * results. + * + The return value will be `undefined` whenever `existingParams` is `undefined` and the global `location`'s search + * parameters are empty. + * @param existingParams Existing `URLSearchParams` from the new URL. * @param preserveQuery The query preservation options. - * @returns The merged URLSearchParams or undefined if no merging is needed. + * @returns The merged `URLSearchParams`, or `undefined`. */ export function mergeQueryParams(existingParams: URLSearchParams | undefined, preserveQuery?: PreserveQuery): URLSearchParams | undefined; export function mergeQueryParams(set1: URLSearchParams | undefined, pqOrSet2: PreserveQuery | URLSearchParams | undefined): URLSearchParams | undefined { From b0eeaf84c810f727386f6783126d7c72cb77e7cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ramirez=20Vargas=2C=20Jos=C3=A9=20Pablo?= Date: Sun, 23 Nov 2025 15:13:38 -0600 Subject: [PATCH 3/5] chore(docs): Correct mispelled word --- src/lib/kernel/buildHref.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/kernel/buildHref.ts b/src/lib/kernel/buildHref.ts index 76b54be..39e6556 100644 --- a/src/lib/kernel/buildHref.ts +++ b/src/lib/kernel/buildHref.ts @@ -11,7 +11,7 @@ import { mergeQueryParams } from "./preserveQuery.js"; * ### When to Use * * This is a helper function that came to be when the redirection feature was added to the library. The specific use - * case is cross-routing-universe redirections, where the "source" universe's path is not changed by normal redireciton + * case is cross-routing-universe redirections, where the "source" universe's path is not changed by normal redirection * because "normal" **cross-universe redirections** don't alter other universes' paths. * * This function, in conjunction with the `calculateHref` function, allows relatively easy construction of the desired From a65033fe6afb78834b9fa230d7ca34a029a426eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ramirez=20Vargas=2C=20Jos=C3=A9=20Pablo?= Date: Sun, 23 Nov 2025 15:15:51 -0600 Subject: [PATCH 4/5] fix(docs): Correct incorrect example --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index 6ba03f3..28993e2 100644 --- a/README.md +++ b/README.md @@ -483,15 +483,12 @@ This is a same-universe example: ```svelte ``` From 02f7956f2fa1b3b970145a4c5b584b56c4cd82d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ramirez=20Vargas=2C=20Jos=C3=A9=20Pablo?= Date: Sun, 23 Nov 2025 15:20:02 -0600 Subject: [PATCH 5/5] refactor: Take buildHref out of kernel Not a kernel function, merely an utilitarian one. --- src/lib/{kernel => }/buildHref.test.ts | 4 ++-- src/lib/{kernel => }/buildHref.ts | 4 ++-- src/lib/index.test.ts | 1 + src/lib/index.ts | 1 + src/lib/kernel/index.test.ts | 1 - src/lib/kernel/index.ts | 1 - 6 files changed, 6 insertions(+), 6 deletions(-) rename src/lib/{kernel => }/buildHref.test.ts (99%) rename src/lib/{kernel => }/buildHref.ts (93%) diff --git a/src/lib/kernel/buildHref.test.ts b/src/lib/buildHref.test.ts similarity index 99% rename from src/lib/kernel/buildHref.test.ts rename to src/lib/buildHref.test.ts index 5c0d939..c6cee7c 100644 --- a/src/lib/kernel/buildHref.test.ts +++ b/src/lib/buildHref.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'vitest'; import { buildHref } from './buildHref.js'; -import { init } from '../init.js'; -import { location } from './Location.js'; +import { init } from './init.js'; +import { location } from './kernel/Location.js'; describe('buildHref', () => { let cleanup: Function; diff --git a/src/lib/kernel/buildHref.ts b/src/lib/buildHref.ts similarity index 93% rename from src/lib/kernel/buildHref.ts rename to src/lib/buildHref.ts index 39e6556..294fda1 100644 --- a/src/lib/kernel/buildHref.ts +++ b/src/lib/buildHref.ts @@ -1,6 +1,6 @@ import type { BuildHrefOptions } from "$lib/types.js"; -import { location } from "./Location.js"; -import { mergeQueryParams } from "./preserveQuery.js"; +import { location } from "./kernel/Location.js"; +import { mergeQueryParams } from "./kernel/preserveQuery.js"; /** * Builds a new HREF by combining the path piece from one HREF and the hash piece from another. diff --git a/src/lib/index.test.ts b/src/lib/index.test.ts index 7fab50d..f7481fd 100644 --- a/src/lib/index.test.ts +++ b/src/lib/index.test.ts @@ -20,6 +20,7 @@ describe('index', () => { 'isRouteActive', 'activeBehavior', 'Redirector', + 'buildHref', ]; // Act. diff --git a/src/lib/index.ts b/src/lib/index.ts index ae8442b..6ad9a5a 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -15,3 +15,4 @@ export { default as RouterTrace } from './RouterTrace/RouterTrace.svelte'; export * from "./public-utils.js"; export * from "./behaviors/active.svelte.js"; export { Redirector } from "./kernel/Redirector.svelte.js"; +export { buildHref } from "./buildHref.js"; diff --git a/src/lib/kernel/index.test.ts b/src/lib/kernel/index.test.ts index 5a8dd36..b33bde1 100644 --- a/src/lib/kernel/index.test.ts +++ b/src/lib/kernel/index.test.ts @@ -17,7 +17,6 @@ describe('index', () => { 'LocationLite', 'LocationFull', 'preserveQueryInUrl', - 'buildHref', 'calculateMultiHashFragment' ]; diff --git a/src/lib/kernel/index.ts b/src/lib/kernel/index.ts index a0b69b6..fee3d66 100644 --- a/src/lib/kernel/index.ts +++ b/src/lib/kernel/index.ts @@ -12,4 +12,3 @@ export { InterceptedHistoryApi } from "./InterceptedHistoryApi.svelte.js"; export { LocationLite } from "./LocationLite.svelte.js"; export { LocationFull } from "./LocationFull.js"; export { preserveQueryInUrl } from "./preserveQuery.js"; -export { buildHref } from "./buildHref.js";