Skip to content

Commit f5be942

Browse files
authored
feat!: Add Redirector (#118)
BREAKING CHANGE
1 parent 65667d8 commit f5be942

23 files changed

+2449
-161
lines changed

README.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
[@svelte-router/kit](https://github.com/WJSoftware/svelte-router-kit)
2626
+ **Electron support**: Works with Electron (all routing modes)
2727
+ **Reactivity-based**: All data is reactive, reducing the need for events and imperative programming.
28+
+ **⚡NEW! URL Redirection**: Use `Redirector` instances to route users from deprecated URL's to new URL's, even across
29+
routing universes.
2830

2931
**Components**:
3032

@@ -470,6 +472,67 @@ As seen, the value of the `href` property never changes. It's always a path, re
470472
At your own risk, you could use exported API like `getRouterContext()` and `setRouterContext()` to perform unholy acts
471473
on the router layouts, again, **at your own risk**.
472474

475+
## URL Redirection
476+
477+
Create `Redirector` class instances to route users from deprecated URL's to new URL's. The redirection can even cross
478+
the routing universe boundary. In other words, URL's from one routing universe can be redirected to a different
479+
routing universe.
480+
481+
This is a same-universe example:
482+
483+
```svelte
484+
<script lang="ts">
485+
import { Redirector } from "@svelte-router/core";
486+
487+
const redirector = new Redirector(/* hash value, or nothing for default universe */);
488+
redirector.redirections.push({
489+
pattern: `/orders/:id`,
490+
href: (rp) => `/profile/my-orders/${rp?.id}`
491+
});
492+
...
493+
</script>
494+
```
495+
496+
The constructor of the class sets a Svelte `$effect` up, so instances of this class must be created in places where
497+
Svelte effects are acceptable, like the initialization code of a component (like in the example).
498+
499+
Redirections are almost identical to route definitions, and even use the same matching algorithm. The `pattern` is
500+
used to match the current URL (it defines the deprecated URL), while `href` defines the new URL users will be
501+
redirected to. As seen in the example, parameters can be defined, and `href`, when written as a function, receives
502+
the route parameters as the first argument.
503+
504+
### Cross-Universe Redirection
505+
506+
Crossing the universe boundary when redirecting is very simple, but there's a catch: Cleaning up the old URL.
507+
508+
```svelte
509+
<script lang="ts">
510+
import { Redirector } from "@svelte-router/core";
511+
512+
const redirector = new Redirector(false);
513+
redirector.redirections.push({
514+
pattern: `/orders/:id`,
515+
href: (rp) => `/profile/my-orders/${rp?.id}`,
516+
options: { hash: true }
517+
});
518+
...
519+
</script>
520+
```
521+
522+
The modifications in the example are:
523+
524+
1. Explicit hash value in the redirector's constructor.
525+
2. Destination hash value specifications via options.
526+
527+
Now comes the aforementioned catch: The "final" URL will be looking like this: `https://example.com/orders/123#/profile/my-orders/123`.
528+
529+
There's no good way for this library to provide a safe way to "clean up" the path in the deprecated routing universe,
530+
so it is up to consumers of this library to clean up. How? The recommendation is to tell the redirector to use
531+
`location.goTo()` and provide a full HREF with all universes accounted for.
532+
533+
See the [Redirecting](https://wjfe-n-savant.hashnode.space/wjfe-n-savant/navigating/redirecting) topic in the online
534+
documentation for full details, including helper functions available to you.
535+
473536
---
474537

475538
[Issues Here](https://github.com/WJSoftware/svelte-router-core/issues)

src/lib/buildHref.test.ts

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'vitest';
2+
import { buildHref } from './buildHref.js';
3+
import { init } from './init.js';
4+
import { location } from './kernel/Location.js';
5+
6+
describe('buildHref', () => {
7+
let cleanup: Function;
8+
beforeAll(() => {
9+
cleanup = init();
10+
});
11+
afterAll(() => {
12+
cleanup();
13+
});
14+
15+
beforeEach(() => {
16+
// Reset to a clean base URL for each test
17+
location.url.href = 'https://example.com/current?currentParam=value';
18+
});
19+
20+
describe('Basic functionality', () => {
21+
test('Should combine path from first HREF and hash from second HREF.', () => {
22+
const pathPiece = 'https://example.com/new-path';
23+
const hashPiece = 'https://example.com/any-path#new-hash';
24+
25+
const result = buildHref(pathPiece, hashPiece);
26+
27+
expect(result).toBe('/new-path#new-hash');
28+
});
29+
30+
test('Should handle relative URLs correctly.', () => {
31+
const pathPiece = '/relative-path';
32+
const hashPiece = '/any-path#relative-hash';
33+
34+
const result = buildHref(pathPiece, hashPiece);
35+
36+
expect(result).toBe('/relative-path#relative-hash');
37+
});
38+
39+
test('Should work when pathPiece has no path component.', () => {
40+
const pathPiece = 'https://example.com/';
41+
const hashPiece = 'https://example.com/#hash-only';
42+
43+
const result = buildHref(pathPiece, hashPiece);
44+
45+
expect(result).toBe('/#hash-only');
46+
});
47+
48+
test('Should work when hashPiece has no hash component.', () => {
49+
const pathPiece = 'https://example.com/path-only';
50+
const hashPiece = 'https://example.com/any-path';
51+
52+
const result = buildHref(pathPiece, hashPiece);
53+
54+
expect(result).toBe('/path-only');
55+
});
56+
57+
test('Should handle empty hash correctly.', () => {
58+
const pathPiece = 'https://example.com/path';
59+
const hashPiece = 'https://example.com/any-path#';
60+
61+
const result = buildHref(pathPiece, hashPiece);
62+
63+
expect(result).toBe('/path');
64+
});
65+
});
66+
67+
describe('Query parameter merging', () => {
68+
test('Should merge query parameters from both pieces.', () => {
69+
const pathPiece = 'https://example.com/path?pathParam=pathValue';
70+
const hashPiece = 'https://example.com/any-path?hashParam=hashValue#hash';
71+
72+
const result = buildHref(pathPiece, hashPiece);
73+
74+
expect(result).toBe('/path?pathParam=pathValue&hashParam=hashValue#hash');
75+
});
76+
77+
test('Should handle query parameters in pathPiece only.', () => {
78+
const pathPiece = 'https://example.com/path?onlyPath=value';
79+
const hashPiece = 'https://example.com/any-path#hash';
80+
81+
const result = buildHref(pathPiece, hashPiece);
82+
83+
expect(result).toBe('/path?onlyPath=value#hash');
84+
});
85+
86+
test('Should handle query parameters in hashPiece only.', () => {
87+
const pathPiece = 'https://example.com/path';
88+
const hashPiece = 'https://example.com/any-path?onlyHash=value#hash';
89+
90+
const result = buildHref(pathPiece, hashPiece);
91+
92+
expect(result).toBe('/path?onlyHash=value#hash');
93+
});
94+
95+
test('Should handle duplicate parameter names by keeping both values.', () => {
96+
const pathPiece = 'https://example.com/path?shared=pathValue';
97+
const hashPiece = 'https://example.com/any-path?shared=hashValue#hash';
98+
99+
const result = buildHref(pathPiece, hashPiece);
100+
101+
expect(result).toBe('/path?shared=pathValue&shared=hashValue#hash');
102+
});
103+
104+
test('Should handle multiple parameters in both pieces.', () => {
105+
const pathPiece = 'https://example.com/path?param1=value1&param2=value2';
106+
const hashPiece = 'https://example.com/any-path?param3=value3&param4=value4#hash';
107+
108+
const result = buildHref(pathPiece, hashPiece);
109+
110+
expect(result).toBe('/path?param1=value1&param2=value2&param3=value3&param4=value4#hash');
111+
});
112+
113+
test('Should work with empty query strings.', () => {
114+
const pathPiece = 'https://example.com/path?';
115+
const hashPiece = 'https://example.com/any-path?#hash';
116+
117+
const result = buildHref(pathPiece, hashPiece);
118+
119+
expect(result).toBe('/path#hash');
120+
});
121+
});
122+
123+
describe('preserveQuery option', () => {
124+
beforeEach(() => {
125+
// Set up current URL with query parameters to preserve
126+
location.url.href = 'https://example.com/current?preserve1=value1&preserve2=value2&preserve3=value3';
127+
});
128+
129+
test('Should preserve all current query parameters when preserveQuery is true.', () => {
130+
const pathPiece = 'https://example.com/path?new=param';
131+
const hashPiece = 'https://example.com/any-path#hash';
132+
133+
const result = buildHref(pathPiece, hashPiece, { preserveQuery: true });
134+
135+
expect(result).toBe('/path?new=param&preserve1=value1&preserve2=value2&preserve3=value3#hash');
136+
});
137+
138+
test('Should preserve specific query parameter when preserveQuery is a string.', () => {
139+
const pathPiece = 'https://example.com/path';
140+
const hashPiece = 'https://example.com/any-path#hash';
141+
142+
const result = buildHref(pathPiece, hashPiece, { preserveQuery: 'preserve2' });
143+
144+
expect(result).toBe('/path?preserve2=value2#hash');
145+
});
146+
147+
test('Should preserve specific query parameters when preserveQuery is an array.', () => {
148+
const pathPiece = 'https://example.com/path';
149+
const hashPiece = 'https://example.com/any-path#hash';
150+
151+
const result = buildHref(pathPiece, hashPiece, { preserveQuery: ['preserve1', 'preserve3'] });
152+
153+
expect(result).toBe('/path?preserve1=value1&preserve3=value3#hash');
154+
});
155+
156+
test('Should not preserve any parameters when preserveQuery is false.', () => {
157+
const pathPiece = 'https://example.com/path?new=param';
158+
const hashPiece = 'https://example.com/any-path#hash';
159+
160+
const result = buildHref(pathPiece, hashPiece, { preserveQuery: false });
161+
162+
expect(result).toBe('/path?new=param#hash');
163+
});
164+
165+
test('Should not preserve any parameters when preserveQuery is not specified.', () => {
166+
const pathPiece = 'https://example.com/path?new=param';
167+
const hashPiece = 'https://example.com/any-path#hash';
168+
169+
const result = buildHref(pathPiece, hashPiece);
170+
171+
expect(result).toBe('/path?new=param#hash');
172+
});
173+
174+
test('Should handle preserveQuery with existing merged parameters.', () => {
175+
const pathPiece = 'https://example.com/path?fromPath=pathVal';
176+
const hashPiece = 'https://example.com/any-path?fromHash=hashVal#hash';
177+
178+
const result = buildHref(pathPiece, hashPiece, { preserveQuery: 'preserve2' });
179+
180+
expect(result).toBe('/path?fromPath=pathVal&fromHash=hashVal&preserve2=value2#hash');
181+
});
182+
183+
test('Should handle non-existent preserve parameter gracefully.', () => {
184+
const pathPiece = 'https://example.com/path';
185+
const hashPiece = 'https://example.com/any-path#hash';
186+
187+
const result = buildHref(pathPiece, hashPiece, { preserveQuery: 'nonExistent' });
188+
189+
expect(result).toBe('/path#hash');
190+
});
191+
});
192+
193+
describe('Edge cases', () => {
194+
test('Should handle both pieces being the same URL.', () => {
195+
const sameUrl = 'https://example.com/same?param=value#hash';
196+
197+
const result = buildHref(sameUrl, sameUrl);
198+
199+
expect(result).toBe('/same?param=value&param=value#hash');
200+
});
201+
202+
test('Should handle URLs with different domains.', () => {
203+
const pathPiece = 'https://other-domain.com/path?param=value';
204+
const hashPiece = 'https://another-domain.com/any-path#hash';
205+
206+
const result = buildHref(pathPiece, hashPiece);
207+
208+
expect(result).toBe('/path?param=value#hash');
209+
});
210+
211+
test('Should handle URLs with special characters in parameters.', () => {
212+
const pathPiece = 'https://example.com/path?special=hello%20world';
213+
const hashPiece = 'https://example.com/any-path?encoded=test%2Bvalue#hash%20with%20spaces';
214+
215+
const result = buildHref(pathPiece, hashPiece);
216+
217+
expect(result).toBe('/path?special=hello+world&encoded=test%2Bvalue#hash%20with%20spaces');
218+
});
219+
220+
test('Should handle root paths correctly.', () => {
221+
const pathPiece = 'https://example.com/';
222+
const hashPiece = 'https://example.com/#root-hash';
223+
224+
const result = buildHref(pathPiece, hashPiece);
225+
226+
expect(result).toBe('/#root-hash');
227+
});
228+
229+
test('Should handle complex hash fragments.', () => {
230+
const pathPiece = 'https://example.com/path';
231+
const hashPiece = 'https://example.com/any-path#/complex/hash/route?hashParam=value';
232+
233+
const result = buildHref(pathPiece, hashPiece);
234+
235+
expect(result).toBe('/path#/complex/hash/route?hashParam=value');
236+
});
237+
});
238+
239+
describe('Cross-universe redirection use case', () => {
240+
test('Should support typical cross-universe redirection scenario.', () => {
241+
// Simulate getting path piece from path router and hash piece from hash router
242+
const pathUniverseHref = 'https://example.com/users/profile?pathParam=value';
243+
const hashUniverseHref = 'https://example.com/current#/dashboard/settings?hashParam=value';
244+
245+
const result = buildHref(pathUniverseHref, hashUniverseHref);
246+
247+
expect(result).toBe('/users/profile?pathParam=value#/dashboard/settings?hashParam=value');
248+
});
249+
250+
test('Should handle preserving current query in cross-universe scenario.', () => {
251+
location.url.href = 'https://example.com/current?globalParam=global&session=active';
252+
253+
const pathUniverseHref = 'https://example.com/users/profile';
254+
const hashUniverseHref = 'https://example.com/current#/dashboard';
255+
256+
const result = buildHref(pathUniverseHref, hashUniverseHref, { preserveQuery: ['session'] });
257+
258+
expect(result).toBe('/users/profile?session=active#/dashboard');
259+
});
260+
});
261+
262+
describe('Additional edge cases', () => {
263+
test('Should handle URL fragments with encoded characters.', () => {
264+
const pathPiece = 'https://example.com/path';
265+
const hashPiece = 'https://example.com/any#%20encoded%20hash';
266+
267+
const result = buildHref(pathPiece, hashPiece);
268+
269+
expect(result).toBe('/path#%20encoded%20hash');
270+
});
271+
272+
test('Should handle when both pieces have same domain but different protocols.', () => {
273+
const pathPiece = 'http://example.com/path';
274+
const hashPiece = 'https://example.com/other#hash';
275+
276+
const result = buildHref(pathPiece, hashPiece);
277+
278+
expect(result).toBe('/path#hash');
279+
});
280+
281+
test('Should handle query parameters with empty values.', () => {
282+
const pathPiece = 'https://example.com/path?empty=';
283+
const hashPiece = 'https://example.com/other?also=&blank=#hash';
284+
285+
const result = buildHref(pathPiece, hashPiece);
286+
287+
expect(result).toBe('/path?empty=&also=&blank=#hash');
288+
});
289+
290+
test('Should handle preserveQuery with empty current URL query.', () => {
291+
location.url.href = 'https://example.com/current'; // No query parameters
292+
293+
const pathPiece = 'https://example.com/path?new=param';
294+
const hashPiece = 'https://example.com/other#hash';
295+
296+
const result = buildHref(pathPiece, hashPiece, { preserveQuery: true });
297+
298+
expect(result).toBe('/path?new=param#hash');
299+
});
300+
301+
test('Should handle complex multi-hash routing fragment.', () => {
302+
const pathPiece = 'https://example.com/app';
303+
const hashPiece = 'https://example.com/other#main=/dashboard;sidebar=/menu';
304+
305+
const result = buildHref(pathPiece, hashPiece);
306+
307+
expect(result).toBe('/app#main=/dashboard;sidebar=/menu');
308+
});
309+
});
310+
});

0 commit comments

Comments
 (0)