sv-router 0.0.5 → 0.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sv-router",
3
- "version": "0.0.5",
4
- "description": "Modern Svelte routing",
3
+ "version": "0.0.7",
4
+ "description": "Modern Svelte Routing",
5
5
  "keywords": [
6
6
  "svelte",
7
7
  "router",
@@ -33,34 +33,37 @@
33
33
  "src"
34
34
  ],
35
35
  "dependencies": {
36
- "esm-env": "^1.2.1"
36
+ "esm-env": "^1.2.2"
37
37
  },
38
38
  "devDependencies": {
39
- "@changesets/cli": "^2.27.10",
40
- "@eslint/js": "^9.16.0",
41
- "@types/node": "^22.10.1",
42
- "eslint-config-prettier": "^9.1.0",
39
+ "@changesets/cli": "^2.28.1",
40
+ "@eslint/js": "^9.21.0",
41
+ "@types/node": "^22.13.8",
42
+ "eslint": "^9.21.0",
43
+ "eslint-config-prettier": "^10.0.2",
43
44
  "eslint-plugin-simple-import-sort": "^12.1.1",
44
- "eslint-plugin-svelte": "^2.46.1",
45
- "eslint-plugin-unicorn": "^56.0.1",
46
- "globals": "^15.13.0",
47
- "prettier": "^3.4.2",
48
- "prettier-plugin-jsdoc": "^1.3.0",
49
- "prettier-plugin-svelte": "^3.3.2",
45
+ "eslint-plugin-svelte": "^3.0.2",
46
+ "eslint-plugin-unicorn": "^57.0.0",
47
+ "globals": "^16.0.0",
48
+ "prettier": "^3.5.2",
49
+ "prettier-plugin-jsdoc": "^1.3.2",
50
+ "prettier-plugin-svelte": "^3.3.3",
51
+ "svelte-check": "^4.1.4",
50
52
  "type-testing": "^0.2.0",
51
- "typescript": "^5.7.2",
52
- "typescript-eslint": "^8.18.0",
53
- "vite": "^6.0.3",
54
- "vitest": "^2.1.8"
53
+ "typescript": "^5.8.2",
54
+ "typescript-eslint": "^8.25.0",
55
+ "vite": "^6.2.0",
56
+ "vitest": "^3.0.7"
55
57
  },
56
58
  "peerDependencies": {
57
59
  "svelte": "^5"
58
60
  },
59
61
  "scripts": {
60
- "ex:config-based": "pnpm --filter config-based-example",
61
- "ex:file-based": "pnpm --filter file-based-example",
62
+ "docs:dev": "pnpm --filter docs dev",
63
+ "docs:build": "pnpm --filter docs build",
64
+ "docs:preview": "pnpm --filter docs preview",
62
65
  "test": "vitest",
63
- "check": "tsc --noEmit && pnpm -r check",
66
+ "check": "svelte-check && pnpm -r check",
64
67
  "lint": "eslint .",
65
68
  "lint:fix": "eslint . --fix",
66
69
  "format": "prettier . --check",
package/src/Router.svelte CHANGED
@@ -6,7 +6,7 @@
6
6
  onNavigate();
7
7
 
8
8
  $effect(() => {
9
- const off1 = on(globalThis, 'popstate', onNavigate);
9
+ const off1 = on(globalThis, 'popstate', () => onNavigate());
10
10
  const off2 = on(globalThis, 'click', onGlobalClick);
11
11
 
12
12
  return () => {
@@ -0,0 +1,17 @@
1
+ import { location } from './create-router.svelte.js';
2
+
3
+ /** @type {import('./index.d.ts').IsActiveLink} */
4
+ export function isActiveLink(node, { className = 'is-active' } = {}) {
5
+ if (node.tagName !== 'A') {
6
+ throw new Error('isActiveLink can only be used on <a> elements');
7
+ }
8
+
9
+ $effect(() => {
10
+ const pathname = new URL(node.href).pathname;
11
+ if (pathname === location.pathname) {
12
+ node.classList.add(className);
13
+ } else {
14
+ node.classList.remove(className);
15
+ }
16
+ });
17
+ }
@@ -1,7 +1,9 @@
1
1
  import { BROWSER, DEV } from 'esm-env';
2
+ import { isActive } from './helpers/is-active.js';
2
3
  import { matchRoute } from './helpers/match-route.js';
3
4
  import { preloadOnHover } from './helpers/preload-on-hover.js';
4
5
  import { constructPath, resolveRouteComponents } from './helpers/utils.js';
6
+ import { syncSearchParams } from './search-params.svelte.js';
5
7
 
6
8
  /** @type {import('./index.d.ts').Routes} */
7
9
  export let routes;
@@ -12,7 +14,7 @@ export let componentTree = $state({ value: [] });
12
14
  /** @type {{ value: Record<string, string> }} */
13
15
  export let params = $state({ value: {} });
14
16
 
15
- let location = $state(updatedLocation());
17
+ export let location = $state(updatedLocation());
16
18
 
17
19
  /**
18
20
  * @template {import('./index.d.ts').Routes} T
@@ -32,24 +34,8 @@ export function createRouter(r) {
32
34
 
33
35
  return {
34
36
  p: constructPath,
35
- /**
36
- * @param {string} path
37
- * @param {import('./index.d.ts').NavigateOptions & { params?: Record<string, string> }} options
38
- */
39
- navigate(path, options = {}) {
40
- if (options.params) {
41
- path = constructPath(path, options.params);
42
- }
43
- if (options.search) {
44
- path += options.search;
45
- }
46
- if (options.hash) {
47
- path += options.hash;
48
- }
49
- const historyMethod = options.replace ? 'replaceState' : 'pushState';
50
- globalThis.history[historyMethod](options.state || {}, '', path);
51
- onNavigate();
52
- },
37
+ navigate,
38
+ isActive,
53
39
  route: {
54
40
  get params() {
55
41
  return params.value;
@@ -70,16 +56,66 @@ export function createRouter(r) {
70
56
  };
71
57
  }
72
58
 
73
- export function onNavigate() {
59
+ /**
60
+ * @param {string | number} path
61
+ * @param {import('./index.d.ts').NavigateOptions & { params?: Record<string, string> }} options
62
+ */
63
+ function navigate(path, options = {}) {
64
+ if (typeof path === 'number') {
65
+ globalThis.history.go(path);
66
+ return;
67
+ }
68
+ if (options.params) {
69
+ path = constructPath(path, options.params);
70
+ }
71
+ if (options.search && !options.search.startsWith('?')) {
72
+ options.search = '?' + options.search;
73
+ }
74
+ if (options.hash && !options.hash.startsWith('#')) {
75
+ options.hash = '#' + options.hash;
76
+ }
77
+ onNavigate(path, options);
78
+ }
79
+
80
+ /**
81
+ * @param {string} [path]
82
+ * @param {import('./index.d.ts').NavigateOptions} options
83
+ */
84
+ export async function onNavigate(path, options = {}) {
74
85
  if (!routes) {
75
86
  throw new Error('Router not initialized: `createRouter` was not called.');
76
87
  }
77
- location = updatedLocation();
78
- const { match, layouts, params: newParams } = matchRoute(globalThis.location.pathname, routes);
88
+ const {
89
+ match,
90
+ layouts,
91
+ hooks,
92
+ params: newParams,
93
+ } = matchRoute(path || globalThis.location.pathname, routes);
94
+
95
+ for (const { beforeLoad } of hooks) {
96
+ try {
97
+ await beforeLoad?.();
98
+ } catch {
99
+ return;
100
+ }
101
+ }
102
+
103
+ componentTree.value = await resolveRouteComponents(match ? [...layouts, match] : layouts);
79
104
  params.value = newParams || {};
80
- resolveRouteComponents(match ? [...layouts, match] : layouts).then((components) => {
81
- componentTree.value = components;
82
- });
105
+
106
+ if (path) {
107
+ if (options.search) path += options.search;
108
+ if (options.hash) path += options.hash;
109
+ const historyMethod = options.replace ? 'replaceState' : 'pushState';
110
+ globalThis.history[historyMethod](options.state || {}, '', path);
111
+ }
112
+
113
+ syncSearchParams();
114
+ Object.assign(location, updatedLocation());
115
+
116
+ for (const { afterLoad } of hooks) {
117
+ afterLoad?.();
118
+ }
83
119
  }
84
120
 
85
121
  /** @param {Event} event */
@@ -95,9 +131,12 @@ export function onGlobalClick(event) {
95
131
 
96
132
  event.preventDefault();
97
133
  const { replace, state } = anchor.dataset;
98
- const historyMethod = replace === undefined || replace === 'false' ? 'pushState' : 'replaceState';
99
- globalThis.history[historyMethod](state || {}, '', anchor.href);
100
- onNavigate();
134
+ onNavigate(url.pathname, {
135
+ replace: replace === '' || replace === 'true',
136
+ search: url.search,
137
+ state,
138
+ hash: url.hash,
139
+ });
101
140
  }
102
141
 
103
142
  function updatedLocation() {
@@ -9,7 +9,9 @@ import path from 'node:path';
9
9
  * }} GeneratedRoutes
10
10
  */
11
11
 
12
- const PARAM_FILENAME_REGEX = /\[(.*)\].svelte/g;
12
+ const PARAM_FILENAME_REGEX = /\[(.*)\](\.lazy)?\.svelte$/; // [any].svelte, [any].lazy.svelte
13
+ const CATCH_ALL_FILENAME_REGEX = /\[\.\.\.(.*)\](\.lazy)?\.svelte$/; // [...any].svelte, [...any].lazy.svelte
14
+ const HOOKS_FILENAME_REGEX = /(hooks)(\.svelte)?\.(js|ts)$/; // hooks.js, hooks.svelte.js, hooks.ts, hooks.svelte.ts
13
15
 
14
16
  /**
15
17
  * @param {string} routesPath
@@ -35,33 +37,12 @@ export function buildFileTree(routesPath) {
35
37
  tree.push({ name: entry, tree: buildFileTree(path.join(routesPath, entry)) });
36
38
  continue;
37
39
  }
38
- if (!entry.endsWith('.svelte')) continue;
39
- handleFlatFilename(tree, entry);
40
- }
41
- return tree;
42
- }
43
-
44
- /**
45
- * @param {FileTree} tree
46
- * @param {string} path
47
- */
48
- function handleFlatFilename(tree, path) {
49
- const splited = path.split('.');
50
- if (splited.length === 2) {
51
- tree.push(path);
52
- return;
53
- }
54
- const first = /** @type {string} */ (splited.shift());
55
- for (const item of tree) {
56
- if (typeof item === 'object' && item.name === first) {
57
- handleFlatFilename(item.tree, splited.join('.'));
58
- return;
40
+ if (!entry.endsWith('.svelte') && !HOOKS_FILENAME_REGEX.test(entry)) {
41
+ continue;
59
42
  }
43
+ tree.push(entry);
60
44
  }
61
- /** @type {FileTree} */
62
- const branch = [];
63
- handleFlatFilename(branch, splited.join('.'));
64
- tree.push({ name: first, tree: branch });
45
+ return tree;
65
46
  }
66
47
 
67
48
  /**
@@ -74,35 +55,56 @@ export function createRouteMap(fileTree, prefix = '') {
74
55
  const result = {};
75
56
  for (const entry of fileTree) {
76
57
  if (typeof entry === 'string') {
77
- switch (entry) {
78
- case 'index.svelte': {
79
- result['/'] = prefix + entry;
80
- break;
81
- }
82
- case '*.svelte': {
83
- result['*'] = prefix + entry;
84
- break;
85
- }
86
- case '_layout.svelte': {
87
- result['layout'] = prefix + entry;
88
- break;
89
- }
90
- default: {
91
- if (PARAM_FILENAME_REGEX.test(entry)) {
92
- result['/' + entry.replaceAll(PARAM_FILENAME_REGEX, ':$1')] = prefix + entry;
93
- break;
94
- }
95
- result['/' + entry.replace('.svelte', '')] = prefix + entry;
96
- break;
58
+ if (!entry.endsWith('.svelte')) {
59
+ if (HOOKS_FILENAME_REGEX.test(entry)) {
60
+ result['hooks'] = prefix + entry;
61
+ continue;
97
62
  }
63
+ continue;
64
+ }
65
+
66
+ if (entry.endsWith('index.svelte') || entry.endsWith('index.lazy.svelte')) {
67
+ const indexEntry = entry.replace(/\.?index(\.lazy)?\.svelte/, '');
68
+ result['/' + (indexEntry ? filePathToRoute(indexEntry) : '')] = prefix + entry;
69
+ continue;
98
70
  }
71
+
72
+ if (entry === 'layout.svelte' || entry === 'layout.lazy.svelte') {
73
+ result['layout'] = prefix + entry;
74
+ continue;
75
+ }
76
+
77
+ const catchAll = CATCH_ALL_FILENAME_REGEX.exec(entry);
78
+ if (catchAll) {
79
+ result['*' + catchAll[1]] = prefix + entry;
80
+ continue;
81
+ }
82
+
83
+ // Match [id].svelte
84
+ if (PARAM_FILENAME_REGEX.test(entry)) {
85
+ result['/' + filePathToRoute(entry.replace(PARAM_FILENAME_REGEX, ':$1'))] = prefix + entry;
86
+ continue;
87
+ }
88
+
89
+ result['/' + filePathToRoute(entry.replace('.svelte', ''))] = prefix + entry;
99
90
  } else {
100
- result['/' + entry.name] = createRouteMap(entry.tree, prefix + entry.name + '/');
91
+ const entryName = filePathToRoute(entry.name);
92
+ result['/' + entryName] = createRouteMap(entry.tree, prefix + entryName + '/');
101
93
  }
102
94
  }
103
95
  return result;
104
96
  }
105
97
 
98
+ /**
99
+ * Replace `.` with `/`, but not `...`
100
+ *
101
+ * @param {string} filename
102
+ * @returns {string}
103
+ */
104
+ function filePathToRoute(filename) {
105
+ return filename.replaceAll(/\.(?!\.\.)/g, '/');
106
+ }
107
+
106
108
  /**
107
109
  * @param {GeneratedRoutes} routes
108
110
  * @param {string} routesPath
@@ -113,14 +115,76 @@ export function createRouterCode(routes, routesPath) {
113
115
  routesPath += '/';
114
116
  }
115
117
 
116
- const jsonRoutes = JSON.stringify(routes, undefined, 2);
117
- const withImports = jsonRoutes.replaceAll(
118
- /"(.*)": "(.*)",?/g,
119
- `"$1": () => import("${routesPath}$2"),`,
120
- );
118
+ /** @type {Map<string, string>} */
119
+ const importsMap = new Map();
120
+
121
+ const withImports = (function handleImports(routes, routesPath) {
122
+ /** @type {GeneratedRoutes} */
123
+ const result = {};
124
+ for (const [key, value] of Object.entries(routes)) {
125
+ if (typeof value === 'object') {
126
+ result[key] = handleImports(value, routesPath);
127
+ } else if (key === 'hooks' || !value.endsWith('.lazy.svelte')) {
128
+ const variableName = pathToCorrectCasing(value);
129
+ importsMap.set(variableName, routesPath + value);
130
+ result[key] = variableName;
131
+ } else {
132
+ result[key] = `() => import('${routesPath}${value}')`;
133
+ }
134
+ }
135
+ return result;
136
+ })(routes, routesPath);
137
+
138
+ const imports = [...importsMap.entries()].map(([key, value]) => {
139
+ if (value.endsWith('.ts')) {
140
+ value = value.replace('.ts', '');
141
+ }
142
+ return `import ${key} from '${value}';`;
143
+ });
144
+
145
+ const stringifiedRoutes = JSON.stringify(withImports, undefined, 2)
146
+ .replaceAll(/"(.*)": /g, `'$1': `)
147
+ .replaceAll(/: "(.*)"/g, ': $1');
148
+
121
149
  return [
122
- 'import { createRouter } from "sv-router";',
123
- '\n\n',
124
- `export const { p, navigate, route } = createRouter(${withImports});`,
125
- ].join('');
150
+ `import { createRouter } from 'sv-router';`,
151
+ ...imports,
152
+ '',
153
+ `export const { p, navigate, isActive, route } = createRouter(${stringifiedRoutes});`,
154
+ ].join('\n');
155
+ }
156
+
157
+ /**
158
+ * @param {string} value
159
+ * @returns {string}
160
+ */
161
+ export function pathToCorrectCasing(value) {
162
+ const parts = /** @type {string[]} */ ([]);
163
+
164
+ /** @param {RegExp} regex */
165
+ function extractLastPart(regex) {
166
+ if (!regex.test(value)) return;
167
+ const exec = /** @type {RegExpExecArray} */ (regex.exec(value));
168
+ if (exec.index > 0) {
169
+ const before = value.slice(0, exec.index - 1);
170
+ parts.push(...before.split(/\/|-|\./));
171
+ }
172
+ return exec[1];
173
+ }
174
+
175
+ const lastPart =
176
+ extractLastPart(CATCH_ALL_FILENAME_REGEX) ||
177
+ extractLastPart(PARAM_FILENAME_REGEX) ||
178
+ extractLastPart(HOOKS_FILENAME_REGEX) ||
179
+ extractLastPart(/([\w-]+)(\.lazy)?\.svelte$/);
180
+ if (!lastPart) {
181
+ throw new Error(`Invalid filename: ${value}`);
182
+ }
183
+ parts.push(...lastPart.split('-'));
184
+
185
+ const uppercased = parts.map((part, index) => {
186
+ if (index === 0 && lastPart === 'hooks') return part;
187
+ return part.charAt(0).toUpperCase() + part.slice(1);
188
+ });
189
+ return uppercased.join('');
126
190
  }
@@ -0,0 +1,31 @@
1
+ import { location } from '../create-router.svelte.js';
2
+ import { constructPath } from './utils.js';
3
+
4
+ /**
5
+ * @param {string} pathname
6
+ * @param {Record<string, string>} [params]
7
+ * @returns {boolean}
8
+ */
9
+ export function isActive(pathname, params) {
10
+ if (!pathname.includes(':')) {
11
+ return pathname === location.pathname;
12
+ }
13
+
14
+ if (params) {
15
+ return constructPath(pathname, params) === location.pathname;
16
+ }
17
+
18
+ const pathParts = pathname.split('/').slice(1);
19
+ const routeParts = location.pathname.split('/').slice(1);
20
+ if (pathParts.length !== routeParts.length) {
21
+ return false;
22
+ }
23
+ for (const [index, pathPart] of pathParts.entries()) {
24
+ const routePart = routeParts[index];
25
+ if (routePart.startsWith(':')) {
26
+ continue;
27
+ }
28
+ return pathPart === routePart;
29
+ }
30
+ return false;
31
+ }
@@ -3,6 +3,8 @@
3
3
  *
4
4
  * @typedef {import('../index.d.ts').RouteComponent} RouteComponent
5
5
  *
6
+ * @typedef {import('../index.d.ts').Hooks} Hooks
7
+ *
6
8
  * @typedef {import('../index.d.ts').Routes} Routes
7
9
  */
8
10
 
@@ -12,6 +14,7 @@
12
14
  * @returns {{
13
15
  * match: RouteComponent | undefined;
14
16
  * layouts: LayoutComponent[];
17
+ * hooks: Hooks[];
15
18
  * params: Record<string, string>;
16
19
  * breakFromLayouts: boolean;
17
20
  * }}
@@ -30,6 +33,9 @@ export function matchRoute(pathname, routes) {
30
33
  /** @type {LayoutComponent[]} */
31
34
  let layouts = [];
32
35
 
36
+ /** @type {Hooks[]} */
37
+ let hooks = [];
38
+
33
39
  /** @type {Record<string, string>} */
34
40
  let params = {};
35
41
 
@@ -48,7 +54,11 @@ export function matchRoute(pathname, routes) {
48
54
  const pathPart = pathParts[index];
49
55
  if (routePart.startsWith(':')) {
50
56
  params[routePart.slice(1)] = pathPart;
51
- } else if (routePart === '*') {
57
+ } else if (routePart.startsWith('*')) {
58
+ const param = routePart.slice(1);
59
+ if (param) {
60
+ params[param] = pathParts.slice(index).join('/');
61
+ }
52
62
  const resolvedPath = /** @type {keyof Routes} */ (
53
63
  (index ? '/' : '') + routeParts.join('/')
54
64
  );
@@ -70,6 +80,10 @@ export function matchRoute(pathname, routes) {
70
80
  layouts.push(routes.layout);
71
81
  }
72
82
 
83
+ if ('hooks' in routes && routes.hooks) {
84
+ hooks.push(routes.hooks);
85
+ }
86
+
73
87
  if (typeof routeMatch === 'function') {
74
88
  if (routeParts.length === pathParts.length) {
75
89
  match = routeMatch;
@@ -83,6 +97,7 @@ export function matchRoute(pathname, routes) {
83
97
  if (result) {
84
98
  match = result.match;
85
99
  params = { ...params, ...result.params };
100
+ hooks.push(...result.hooks);
86
101
  if (result.breakFromLayouts) {
87
102
  layouts = [];
88
103
  } else {
@@ -93,7 +108,7 @@ export function matchRoute(pathname, routes) {
93
108
  }
94
109
  }
95
110
 
96
- return { match, layouts, params, breakFromLayouts };
111
+ return { match, layouts, hooks, params, breakFromLayouts };
97
112
  }
98
113
 
99
114
  /**
@@ -110,7 +125,7 @@ export function sortRoutes(routes) {
110
125
  */
111
126
  function getRoutePriority(route) {
112
127
  if (route === '' || route === '/') return 1;
113
- if (route === '*') return 4;
128
+ if (route.startsWith('*')) return 4;
114
129
  if (route.includes(':')) return 3;
115
130
  return 2;
116
131
  }
@@ -42,5 +42,5 @@ export function resolveRouteComponent(input) {
42
42
  * @returns {input is import('../index.d.ts').LazyRouteComponent}
43
43
  */
44
44
  export function isLazyImport(input) {
45
- return typeof input === 'function' && !!/\(\)\s?=>\s?import\(.*\)/g.test(String(input));
45
+ return typeof input === 'function' && !!/\(\)\s?=>\s?import\(.*\)/.test(String(input));
46
46
  }
package/src/index.d.ts CHANGED
@@ -1,10 +1,21 @@
1
1
  import type { Component, Snippet } from 'svelte';
2
+ import type { Action } from 'svelte/action';
2
3
 
4
+ /**
5
+ * A Svelte action that will add a class to the anchor if its `href` matches the current route. It
6
+ * can have an optional `className` parameter to specify the class to add, otherwise it will default
7
+ * to `is-active`.
8
+ *
9
+ * ```svelte
10
+ * <a href="/about" use:isActiveLink={{ className: 'active-link' }}>
11
+ * ```
12
+ */
13
+ export const isActiveLink: IsActiveLink;
3
14
  /**
4
15
  * Setup a new router instance with the given routes.
5
16
  *
6
17
  * ```js
7
- * export const { p, navigate, route } = createRouter({
18
+ * export const { p, navigate, isActive, route } = createRouter({
8
19
  * '/': Home,
9
20
  * '/about': About,
10
21
  * ...
@@ -12,7 +23,13 @@ import type { Component, Snippet } from 'svelte';
12
23
  * ```
13
24
  */
14
25
  export function createRouter<T extends Routes>(r: T): RouterApi<T>;
26
+ /** The component that will render the current route. */
15
27
  export const Router: Component;
28
+ /**
29
+ * The reactive search params of the URL. It is just a wrapper around `SvelteURLSearchParam` that
30
+ * will update the url on change.
31
+ */
32
+ export const searchParams: URLSearchParams;
16
33
 
17
34
  // eslint-disable-next-line @typescript-eslint/no-empty-object-type
18
35
  type BaseProps = {};
@@ -25,13 +42,34 @@ export type RouteComponent<Props extends BaseProps = any> =
25
42
  | Component<Props>
26
43
  | LazyRouteComponent<Props>;
27
44
  export type LayoutComponent = RouteComponent<{ children: Snippet }>;
45
+ export type Hooks = {
46
+ /**
47
+ * A function that will be called before the route is loaded. If it returns a promise, the route
48
+ * will wait for it to resolve before loading.
49
+ *
50
+ * You can throw a `navigate` call to redirect to another route.
51
+ *
52
+ * ```js
53
+ * async beforeLoad() {
54
+ * await ...
55
+ * throw navigate('/home');
56
+ * }
57
+ * ```
58
+ */
59
+ beforeLoad?(): void | Promise<void>;
60
+ /** A function that will be called after the route is loaded. */
61
+ afterLoad?(): void;
62
+ };
28
63
 
29
64
  export type Routes = {
30
65
  [_: `/${string}`]: RouteComponent | Routes;
31
- '*'?: RouteComponent;
66
+ [_: `*${string}`]: RouteComponent | undefined;
32
67
  layout?: LayoutComponent;
68
+ hooks?: Hooks;
33
69
  };
34
70
 
71
+ export type IsActiveLink = Action<HTMLAnchorElement, { className?: string } | undefined>;
72
+
35
73
  export type RouterApi<T extends Routes> = {
36
74
  /**
37
75
  * Construct a path while ensuring type safety.
@@ -57,12 +95,26 @@ export type RouterApi<T extends Routes> = {
57
95
  * id: 1,
58
96
  * },
59
97
  * });
98
+ * // Back and forward
99
+ * navigate(-1);
100
+ * navigate(2);
60
101
  * ```
61
102
  *
62
103
  * @param route The route to navigate to.
63
104
  * @param options The navigation options.
64
105
  */
65
106
  navigate<U extends Path<T>>(...args: NavigateArgs<U>): void;
107
+
108
+ /**
109
+ * Will return `true` if the given path is active.
110
+ *
111
+ * Can be used with params to check the exact path, or without to check for any params in the
112
+ * path.
113
+ *
114
+ * @param path The route to check.
115
+ * @param params The optional parameters to replace in the route.
116
+ */
117
+ isActive<U extends Path<T>>(...args: IsActiveArgs<U>): boolean;
66
118
  route: {
67
119
  /**
68
120
  * An object containing the parameters of the current route.
@@ -90,6 +142,9 @@ export type Path<T extends Routes> = RemoveParenthesis<
90
142
  export type ConstructPathArgs<T extends string> =
91
143
  PathParams<T> extends never ? [T] : [T, PathParams<T>];
92
144
 
145
+ export type IsActiveArgs<T extends string> =
146
+ PathParams<T> extends never ? [T] : [T] | [T, PathParams<T>];
147
+
93
148
  export type PathParams<T extends string> =
94
149
  ExtractParams<T> extends never ? never : Record<ExtractParams<T>, string>;
95
150
 
@@ -100,19 +155,24 @@ export type NavigateOptions =
100
155
  replace?: boolean;
101
156
  search?: string;
102
157
  state?: string;
103
- hash?: `#${string}`;
158
+ hash?: string;
104
159
  }
105
160
  | undefined;
106
161
 
107
- export type NavigateArgs<T extends string> =
108
- PathParams<T> extends never
109
- ? [T, NavigateOptions]
110
- : [T, NavigateOptions & { params: PathParams<T> }];
162
+ type NavigateArgs<T extends string> =
163
+ | (PathParams<T> extends never
164
+ ? [T] | [T, NavigateOptions]
165
+ : [T, NavigateOptions & { params: PathParams<T> }])
166
+ | [number];
111
167
 
112
168
  type StripNonRoutes<T extends Routes> = {
113
- [K in keyof T as K extends '*' ? never : K extends 'layout' ? never : K]: T[K] extends Routes
114
- ? StripNonRoutes<T[K]>
115
- : T[K];
169
+ [K in keyof T as K extends `*${string}`
170
+ ? never
171
+ : K extends 'layout'
172
+ ? never
173
+ : K extends 'hooks'
174
+ ? never
175
+ : K]: T[K] extends Routes ? StripNonRoutes<T[K]> : T[K];
116
176
  };
117
177
 
118
178
  type RecursiveKeys<T extends Routes, Prefix extends string = ''> = {
@@ -133,4 +193,8 @@ type ExtractParams<T extends string> = T extends `${string}:${infer Param}/${inf
133
193
  ? Param | ExtractParams<`/${Rest}`>
134
194
  : T extends `${string}:${infer Param}`
135
195
  ? Param
136
- : never;
196
+ : T extends `${string}*${infer Param}`
197
+ ? Param extends ''
198
+ ? never
199
+ : Param
200
+ : never;
package/src/index.js CHANGED
@@ -1,2 +1,4 @@
1
+ export { isActiveLink } from './actions.svelte.js';
1
2
  export { createRouter } from './create-router.svelte.js';
2
3
  export { default as Router } from './Router.svelte';
4
+ export { searchParams } from './search-params.svelte.js';
@@ -0,0 +1,78 @@
1
+ import { SvelteURLSearchParams } from 'svelte/reactivity';
2
+
3
+ let searchParams = new SvelteURLSearchParams(globalThis.location.search);
4
+
5
+ /** @type {URLSearchParams} */
6
+ const shell = {
7
+ append(...args) {
8
+ searchParams.append(...args);
9
+ updateUrlSearchParams();
10
+ },
11
+ delete(...args) {
12
+ searchParams.delete(...args);
13
+ updateUrlSearchParams();
14
+ },
15
+ entries() {
16
+ return searchParams.entries();
17
+ },
18
+ forEach(...args) {
19
+ // eslint-disable-next-line unicorn/no-array-for-each
20
+ return searchParams.forEach(...args);
21
+ },
22
+ get(...args) {
23
+ return searchParams.get(...args);
24
+ },
25
+ getAll(...args) {
26
+ return searchParams.getAll(...args);
27
+ },
28
+ has(...args) {
29
+ return searchParams.has(...args);
30
+ },
31
+ keys() {
32
+ return searchParams.keys();
33
+ },
34
+ set(...args) {
35
+ searchParams.set(...args);
36
+ updateUrlSearchParams();
37
+ },
38
+ sort() {
39
+ searchParams.sort();
40
+ updateUrlSearchParams();
41
+ },
42
+ toString() {
43
+ return searchParams.toString();
44
+ },
45
+ values() {
46
+ return searchParams.values();
47
+ },
48
+ get size() {
49
+ return searchParams.size;
50
+ },
51
+ [Symbol.iterator]() {
52
+ return searchParams[Symbol.iterator]();
53
+ },
54
+ };
55
+
56
+ export { shell as searchParams };
57
+
58
+ export function syncSearchParams() {
59
+ const newSearchParams = new URLSearchParams(globalThis.location.search);
60
+ if (searchParams.toString() === newSearchParams.toString()) {
61
+ return;
62
+ }
63
+
64
+ for (const key of searchParams.keys()) {
65
+ searchParams.delete(key);
66
+ }
67
+ for (const [key, value] of newSearchParams.entries()) {
68
+ searchParams.append(key, value);
69
+ }
70
+ }
71
+
72
+ function updateUrlSearchParams() {
73
+ let url = globalThis.location.origin + globalThis.location.pathname;
74
+ if (searchParams.size > 0) {
75
+ url += '?' + searchParams.toString();
76
+ }
77
+ globalThis.history.pushState({}, '', url);
78
+ }