Skip to content

Commit ddf3d53

Browse files
committed
feat(navigation): adds meta & title navigation override
1 parent 5e40ca5 commit ddf3d53

File tree

10 files changed

+152
-9
lines changed

10 files changed

+152
-9
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,8 @@ Router navigation support several [options](https://github.com/dvcol/svelte-simp
612612
- `params` to pass route parameters.
613613
- `query` to pass query parameters.
614614
- `state` to pass an history state object.
615+
- `meta` to pass an arbitrary object to the route (will be merged with the route meta).
616+
- `title` to set the document title (will override the route title).
615617
- `stripQuery` to remove current query parameters from the url.
616618
- `stripHash` to remove the hash from the url (only in path mode).
617619
- `stripTrailingHash` to remove the trailing hash from the url (only in hash mode).

src/lib/components/debug/RouteDebugger.svelte

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@
44
const router = useRouter();
55
const route = $derived(router?.route);
66
const location = $derived(router?.location);
7-
const meta = $derived(route?.meta);
7+
const meta = $derived(location?.meta);
8+
const title = $derived(location?.title);
89
const error = $derived<any>(router?.error);
910
</script>
1011

1112
<div class="debug">
1213
<h3>Router - {router?.id}</h3>
1314
<br />
1415
<div>Location - {location?.path}</div>
16+
<div>Title - {title}</div>
1517
<div>Params - {JSON.stringify(location?.params, undefined, 2)}</div>
1618
<div>Query - {JSON.stringify(location?.query, undefined, 2)}</div>
1719
<div>Wildcards - {JSON.stringify(location?.wildcards, undefined, 2)}</div>

src/lib/models/link.model.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ export function getLinkNavigateFunction(options: LinkNavigateOptions = {}): Link
103103
addIfFound(navigation, 'query', options.query ?? parseJsonAttribute(node, 'query'));
104104
addIfFound(navigation, 'params', options.params ?? parseJsonAttribute(node, 'params'));
105105
addIfFound(navigation, 'state', options.state ?? parseJsonAttribute(node, 'state'));
106+
addIfFound(navigation, 'meta', options.meta ?? parseJsonAttribute(node, 'meta'));
107+
addIfFound(navigation, 'title', options.title ?? parseJsonAttribute(node, 'title'));
106108
addIfFound(navigation, 'stripQuery', options.stripQuery ?? parseBooleanAttribute(node, 'strip-query'));
107109
addIfFound(navigation, 'stripHash', options.stripHash ?? parseBooleanAttribute(node, 'strip-hash'));
108110
addIfFound(navigation, 'stripTrailingHash', options.stripTrailingHash ?? parseBooleanAttribute(node, 'strip-trailing-hash'));

src/lib/models/route.model.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ export type HistoryState<Key extends string | number = string | number> = {
2222
[x in Key]: HistoryStateValue<Key>;
2323
};
2424

25+
type PrimitiveKey = string | number | symbol;
26+
export type RouteName = PrimitiveKey;
27+
export type RouteMeta<T = unknown> = Record<PrimitiveKey, T>;
2528
export type RouteParamValue = string | number | boolean;
2629
export type RouteQuery = Record<string, RouteParamValue>;
2730
export type RouteParams = Record<string, RouteParamValue>;
@@ -63,6 +66,23 @@ export type CommonRouteNavigation = RouteNavigationOptions & {
6366
* @see [MDN for more information](https://developer.mozilla.org/en-US/docs/Web/API/History/state)
6467
*/
6568
state?: HistoryState;
69+
/**
70+
* Arbitrary data attached to the record.
71+
*/
72+
meta?: RouteMeta;
73+
/**
74+
* Title of the route record. Used for the document title.
75+
*
76+
* Supports:
77+
* - parameters `:param`.
78+
* - optional parameters `:param:?`.
79+
* - typed parameters `:{string}:param` or `:{number}:param`.
80+
*
81+
* Note: Parameters need to match the following regex: `/:(\w|[:?{}])+/g`.
82+
*
83+
* @example `:count:? My Title :route:?` with parameters `{ count: '(1) ', route: '- home' }` will render `(1) My Title - home`.
84+
*/
85+
title?: string;
6686
};
6787

6888
/**
@@ -79,8 +99,6 @@ export type RouteLocationNavigation = CommonRouteNavigation & {
7999
name?: never;
80100
};
81101

82-
export type RouteName = string | number | symbol;
83-
84102
export type RouteNameNavigation<Name extends RouteName = RouteName> = CommonRouteNavigation & {
85103
/**
86104
* Name of the route.
@@ -94,7 +112,7 @@ export type RouteNameNavigation<Name extends RouteName = RouteName> = CommonRout
94112

95113
export type RouteNavigation<Name extends RouteName = RouteName> = RouteLocationNavigation | RouteNameNavigation<Name>;
96114

97-
export type ComponentProps = Record<RouteName, unknown>;
115+
export type ComponentProps = Record<PrimitiveKey, unknown>;
98116

99117
export interface RouteComponent {
100118
/**
@@ -250,7 +268,7 @@ export interface BaseRoute<Name extends RouteName = RouteName> {
250268
/**
251269
* Arbitrary data attached to the record.
252270
*/
253-
meta?: Record<RouteName, unknown>;
271+
meta?: RouteMeta;
254272
/**
255273
* Default, query parameters to inject in the url when navigating to this route.
256274
* Note that query passed in navigation events will override these.
@@ -350,4 +368,12 @@ export interface ResolvedRoute<Name extends RouteName = RouteName> {
350368
* Wildcards parsed from the path.
351369
*/
352370
wildcards: RouteWildcards;
371+
/**
372+
* Arbitrary data attached to the record.
373+
*/
374+
meta: RouteMeta;
375+
/**
376+
* Document title for the route.
377+
*/
378+
title?: string;
353379
}

src/lib/models/router.model.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
ParsedRoute,
1414
ResolvedRoute,
1515
Route,
16+
RouteMeta,
1617
RouteName,
1718
RouteNavigation,
1819
RouteNavigationOptions,
@@ -27,14 +28,47 @@ import { isShallowEqual } from '@dvcol/common-utils/common/object';
2728
import { isRouteEqual } from '~/models/route.model.js';
2829

2930
export interface RouterLocation<Name extends RouteName = RouteName> {
31+
/**
32+
* Origin of the location.
33+
*/
3034
origin: string;
35+
/**
36+
* Base URL from which the app is served.
37+
*/
3138
base?: string;
39+
/**
40+
* Name of the resolved route record.
41+
*/
3242
name?: Name;
43+
/**
44+
* Resolved path with query and params.
45+
*/
3346
path: string;
47+
/**
48+
* Full matched path including parents and base.
49+
*/
3450
href: URL;
51+
/**
52+
* Query parameters of the location.
53+
*/
3554
query: RouteQuery;
55+
/**
56+
* Params of the location.
57+
*/
3658
params: RouteParams;
59+
/**
60+
* Wildcards parsed from the path.
61+
*/
3762
wildcards: RouteWildcards;
63+
/**
64+
* Arbitrary data attached to the record.
65+
*/
66+
meta: RouteMeta;
67+
/**
68+
* The title template for the location.
69+
* If you need the parsed title, please use the `title` property of the router.
70+
*/
71+
title?: string;
3872
}
3973

4074
export function isLocationEqual<Name extends RouteName = RouteName>(a?: RouterLocation<Name>, b?: RouterLocation<Name>): boolean {
@@ -53,6 +87,8 @@ export function toBasicRouterLocation<Name extends RouteName = RouteName>(loc?:
5387
query: { ...loc.query },
5488
params: { ...loc.params },
5589
wildcards: { ...loc.wildcards },
90+
meta: { ...loc.meta },
91+
title: loc.title,
5692
};
5793
}
5894

src/lib/router/router.svelte.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,8 @@ export class Router<Name extends RouteName = RouteName> implements IRouter<Name>
605605
params,
606606
path,
607607
name,
608+
meta,
609+
title,
608610
stripQuery = this.options.stripQuery,
609611
stripHash = this.options.stripHash,
610612
stripTrailingHash = this.options.stripTrailingHash,
@@ -663,6 +665,8 @@ export class Router<Name extends RouteName = RouteName> implements IRouter<Name>
663665
query: Object.fromEntries(search),
664666
params: { ...params, ..._params },
665667
wildcards: { ...wildcards },
668+
meta: { ...route?.meta, ...meta },
669+
title: title ?? route?.title,
666670
};
667671

668672
const guard = await route?.beforeResolve?.(resolved);
@@ -857,7 +861,7 @@ export class Router<Name extends RouteName = RouteName> implements IRouter<Name>
857861
): Promise<ResolvedRouterLocationSnapshot<Name>> {
858862
const resolved = await this.resolve(to, options);
859863
const routed = await this.#navigate(resolved, options);
860-
const { state, title } = routeToHistoryState(routed, { ...options, state: to.state });
864+
const { state, title } = routeToHistoryState(routed, { ...options, state: to.state, title: to.title });
861865
try {
862866
this.#history[method](state, title ?? '', routed.location?.href);
863867
if (title) document.title = title;

src/lib/utils/navigation.utils.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ import { replaceTitleParams } from '~/models/matcher.model.js';
1010
import { RouterScrollConstant, RouterStateConstant } from '~/models/router.model.js';
1111

1212
export function routeToHistoryState<Name extends RouteName = RouteName>({ route, location }: Partial<ResolvedRouterLocationSnapshot<Name>>, {
13+
title,
1314
metaAsState,
1415
nameAsTitle,
1516
state,
1617
scrollState = { x: globalThis?.scrollX, y: globalThis?.scrollY },
1718
}: {
19+
title?: string;
1820
metaAsState?: boolean;
1921
nameAsTitle?: boolean;
2022
state?: HistoryState;
@@ -26,7 +28,7 @@ export function routeToHistoryState<Name extends RouteName = RouteName>({ route,
2628
const { href, query, params, name, path } = location ?? {};
2729
const _name = name ?? route?.name;
2830
const _path = path ?? route?.path;
29-
const title: string | undefined = route?.title ?? (nameAsTitle ? _name?.toString() : undefined);
31+
const _title: string | undefined = title ?? route?.title ?? (nameAsTitle ? _name?.toString() : undefined);
3032
const routerState: RouterStateLocation<Name> = {};
3133
if (metaAsState && route?.meta) routerState.meta = JSON.parse(JSON.stringify(route.meta)) as RouterStateLocation<Name>['meta'];
3234
if (name) routerState.name = _name;
@@ -41,7 +43,7 @@ export function routeToHistoryState<Name extends RouteName = RouteName>({ route,
4143
[RouterStateConstant]: routerState,
4244
[RouterScrollConstant]: scrollState,
4345
} as RouterState<Name>,
44-
title: title?.length ? replaceTitleParams(title, params) : title,
46+
title: _title?.length ? replaceTitleParams(_title, params) : _title,
4547
};
4648
}
4749

test/link/Link.test.svelte

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@
5050
query: { search: 'value', filter: 42 },
5151
params: { id: 12, name: 'john' },
5252
state: { key: 'value' },
53+
meta: { key: 'value', nested: { key: 'value' } },
54+
title: 'override title',
5355
stripQuery: true,
5456
stripHash: true,
5557
stripTrailingHash: true,

test/link/link.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ describe('link', () => {
122122
query: { search: 'value', filter: 42 },
123123
params: { id: 12, name: 'john' },
124124
state: { key: 'value' },
125+
meta: { key: 'value', nested: { key: 'value' } },
126+
title: 'override title',
125127
stripQuery: true,
126128
stripHash: true,
127129
stripTrailingHash: true,

test/router/router.test.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,18 @@ describe('router', () => {
4747
children: [ChildRoute, OtherChildRoute],
4848
};
4949

50+
const MetaRoute: Route = {
51+
name: 'meta',
52+
path: '/meta',
53+
meta: {
54+
seed: 'Route meta',
55+
nested: {
56+
key: 'Nested key',
57+
value: 'Nested value',
58+
},
59+
},
60+
};
61+
5062
const ParamRoute: Route = {
5163
name: 'param',
5264
path: '/param/:id/user/:firstName:?/:lastName',
@@ -138,6 +150,7 @@ describe('router', () => {
138150
PathRoute,
139151
OtherRoute,
140152
ParentRoute,
153+
MetaRoute,
141154
ParamRoute,
142155
QueryRoute,
143156
ParamQueryRoute,
@@ -668,6 +681,58 @@ describe('router', () => {
668681
expect(route.route.path).toBe('/parent/child');
669682
});
670683

684+
it('should resolve a route from a location with title template', async () => {
685+
expect.assertions(5);
686+
687+
const path = TitleRoute.path;
688+
const route = await router.resolve({ path });
689+
690+
expect(route).toBeDefined();
691+
expect(route.name).toBe(TitleRoute.name);
692+
expect(route.path).toBe(path);
693+
expect(route.route?.path).toBe(TitleRoute.path);
694+
expect(route.title).toStrictEqual(TitleRoute.title);
695+
});
696+
697+
it('should resolve a route from a location with title override', async () => {
698+
expect.assertions(5);
699+
700+
const path = TitleRoute.path;
701+
const route = await router.resolve({ path, title: 'custom title template' });
702+
703+
expect(route).toBeDefined();
704+
expect(route.name).toBe(TitleRoute.name);
705+
expect(route.path).toBe(path);
706+
expect(route.route?.path).toBe(TitleRoute.path);
707+
expect(route.title).toStrictEqual('custom title template');
708+
});
709+
710+
it('should resolve a route from a location with meta object', async () => {
711+
expect.assertions(5);
712+
713+
const path = MetaRoute.path;
714+
const route = await router.resolve({ path });
715+
716+
expect(route).toBeDefined();
717+
expect(route.name).toBe(MetaRoute.name);
718+
expect(route.path).toBe(path);
719+
expect(route.route?.path).toBe(MetaRoute.path);
720+
expect(route.meta).toStrictEqual(MetaRoute.meta);
721+
});
722+
723+
it('should resolve a route from a location and merge meta object', async () => {
724+
expect.assertions(5);
725+
726+
const path = MetaRoute.path;
727+
const route = await router.resolve({ path, meta: { push: 'push value', nested: { key: 'new key', value: 'new key' } } });
728+
729+
expect(route).toBeDefined();
730+
expect(route.name).toBe(MetaRoute.name);
731+
expect(route.path).toBe(path);
732+
expect(route.route?.path).toBe(MetaRoute.path);
733+
expect(route.meta).toStrictEqual({ ...MetaRoute.meta, push: 'push value', nested: { key: 'new key', value: 'new key' } });
734+
});
735+
671736
it('should resolve a route from a location with param parameters', async () => {
672737
expect.assertions(5);
673738

@@ -705,7 +770,7 @@ describe('router', () => {
705770
expect(route.query).toStrictEqual({ page: '2', limit: '5' });
706771
});
707772

708-
it('should resolve a route from a location with default query parameters', async () => {
773+
it('should resolve a route from a name with default query parameters', async () => {
709774
expect.assertions(5);
710775

711776
const route = await router.resolve({ name: QueryRoute.name });

0 commit comments

Comments
 (0)