sv-router 0.0.4 → 0.0.6

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,6 +1,6 @@
1
1
  {
2
2
  "name": "sv-router",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "Modern Svelte routing",
5
5
  "keywords": [
6
6
  "svelte",
@@ -33,31 +33,31 @@
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.27.11",
40
+ "@eslint/js": "^9.18.0",
41
+ "@types/node": "^22.10.7",
42
+ "eslint-config-prettier": "^10.0.1",
43
43
  "eslint-plugin-simple-import-sort": "^12.1.1",
44
44
  "eslint-plugin-svelte": "^2.46.1",
45
45
  "eslint-plugin-unicorn": "^56.0.1",
46
- "globals": "^15.13.0",
46
+ "globals": "^15.14.0",
47
47
  "prettier": "^3.4.2",
48
- "prettier-plugin-jsdoc": "^1.3.0",
49
- "prettier-plugin-svelte": "^3.3.2",
48
+ "prettier-plugin-jsdoc": "^1.3.2",
49
+ "prettier-plugin-svelte": "^3.3.3",
50
50
  "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"
51
+ "typescript": "^5.7.3",
52
+ "typescript-eslint": "^8.20.0",
53
+ "vite": "^6.0.7",
54
+ "vitest": "^3.0.2"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "svelte": "^5"
58
58
  },
59
59
  "scripts": {
60
- "ex:basic": "pnpm --filter basic-example",
60
+ "ex:config-based": "pnpm --filter config-based-example",
61
61
  "ex:file-based": "pnpm --filter file-based-example",
62
62
  "test": "vitest",
63
63
  "check": "tsc --noEmit && pnpm -r check",
@@ -1,5 +1,5 @@
1
1
  <script>
2
- import { paramsStore } from './create-router.svelte.js';
2
+ import { params } from './create-router.svelte.js';
3
3
  import RecursiveComponentTree from './RecursiveComponentTree.svelte';
4
4
 
5
5
  /** @type {{ tree: import('svelte').Component[] }} */
@@ -9,7 +9,7 @@
9
9
  const restTree = $derived(tree.slice(1));
10
10
  </script>
11
11
 
12
- {#key restTree.length > 0 || Object.values(paramsStore)}
12
+ {#key restTree.length > 0 || Object.values(params.value)}
13
13
  <FirstComponent>
14
14
  {#if restTree.length > 0}
15
15
  <RecursiveComponentTree tree={restTree}></RecursiveComponentTree>
package/src/Router.svelte CHANGED
@@ -16,4 +16,4 @@
16
16
  });
17
17
  </script>
18
18
 
19
- <RecursiveComponentTree tree={componentTree}></RecursiveComponentTree>
19
+ <RecursiveComponentTree tree={componentTree.value}></RecursiveComponentTree>
@@ -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
+ }
package/src/cli/index.js CHANGED
@@ -5,9 +5,23 @@ import { writeRouterCode } from '../gen/write-router-code.js';
5
5
 
6
6
  const args = process.argv.slice(2).flatMap((arg) => arg.split('='));
7
7
 
8
- const pathArgIndex = args.indexOf('--path');
9
- if (pathArgIndex !== -1 && pathArgIndex + 1 < args.length) {
10
- genConfig.routesPath = args[pathArgIndex + 1];
8
+ /**
9
+ * @param {keyof import('../vite-plugin/index.d.ts').RouterOptions} option
10
+ * @returns
11
+ */
12
+ function arg(option) {
13
+ const pathArgIndex = args.indexOf('--' + option);
14
+ if (pathArgIndex === -1) return;
15
+ if (pathArgIndex + 1 < args.length) {
16
+ return args[pathArgIndex + 1];
17
+ }
18
+ return args[pathArgIndex];
11
19
  }
12
20
 
21
+ const pathArg = arg('path');
22
+ if (pathArg) genConfig.routesPath = pathArg;
23
+
24
+ const jsArg = arg('js');
25
+ if (jsArg) genConfig.routesInJs = true;
26
+
13
27
  writeRouterCode();
@@ -1,20 +1,25 @@
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';
4
+ import { preloadOnHover } from './helpers/preload-on-hover.js';
3
5
  import { constructPath, resolveRouteComponents } from './helpers/utils.js';
6
+ import { syncSearchParams } from './search-params.svelte.js';
4
7
 
5
8
  /** @type {import('./index.d.ts').Routes} */
6
9
  export let routes;
7
10
 
8
- /** @type {import('svelte').Component[]} */
9
- export const componentTree = $state([]);
11
+ /** @type {{ value: import('svelte').Component[] }} */
12
+ export let componentTree = $state({ value: [] });
10
13
 
11
- /** @type {Record<string, string>} */
12
- export const paramsStore = $state({});
14
+ /** @type {{ value: Record<string, string> }} */
15
+ export let params = $state({ value: {} });
16
+
17
+ export let location = $state(updatedLocation());
13
18
 
14
19
  /**
15
20
  * @template {import('./index.d.ts').Routes} T
16
21
  * @param {T} r
17
- * @returns {import('./index.d.ts').RouterMethods<T>}
22
+ * @returns {import('./index.d.ts').RouterApi<T>}
18
23
  */
19
24
  export function createRouter(r) {
20
25
  routes = r;
@@ -25,29 +30,67 @@ export function createRouter(r) {
25
30
  });
26
31
  }
27
32
 
33
+ preloadOnHover(routes);
34
+
28
35
  return {
29
- path: constructPath,
30
- goto(...args) {
31
- const path = constructPath(args[0], args[1]);
32
- globalThis.history.pushState({}, '', path);
33
- onNavigate();
34
- },
35
- params() {
36
- const readonly = $derived(paramsStore);
37
- return readonly;
36
+ p: constructPath,
37
+ navigate,
38
+ isActive,
39
+ route: {
40
+ get params() {
41
+ return params.value;
42
+ },
43
+ get pathname() {
44
+ return location.pathname;
45
+ },
46
+ get search() {
47
+ return location.search;
48
+ },
49
+ get state() {
50
+ return location.state;
51
+ },
52
+ get hash() {
53
+ return location.hash;
54
+ },
38
55
  },
39
56
  };
40
57
  }
41
58
 
59
+ /**
60
+ * @param {string} path
61
+ * @param {import('./index.d.ts').NavigateOptions & { params?: Record<string, string> }} options
62
+ */
63
+ function navigate(path, options = {}) {
64
+ if (options.params) {
65
+ path = constructPath(path, options.params);
66
+ }
67
+ if (options.search) {
68
+ path += (options.search.startsWith('?') ? '' : '?') + options.search;
69
+ }
70
+ if (options.hash) {
71
+ path += options.hash;
72
+ }
73
+ const historyMethod = options.replace ? 'replaceState' : 'pushState';
74
+ globalThis.history[historyMethod](options.state || {}, '', path);
75
+ onNavigate();
76
+ }
77
+ navigate.back = () => globalThis.history.back();
78
+ navigate.forward = () => globalThis.history.forward();
79
+
42
80
  export function onNavigate() {
43
81
  if (!routes) {
44
82
  throw new Error('Router not initialized: `createRouter` was not called.');
45
83
  }
46
- const { match, layouts, params } = matchRoute(globalThis.location.pathname, routes);
84
+
85
+ syncSearchParams();
86
+
87
+ Object.assign(location, updatedLocation());
88
+
89
+ const { match, layouts, params: newParams } = matchRoute(globalThis.location.pathname, routes);
90
+ params.value = newParams || {};
47
91
  resolveRouteComponents(match ? [...layouts, match] : layouts).then((components) => {
48
- Object.assign(componentTree, components);
92
+ componentTree.value = components;
49
93
  });
50
- Object.assign(paramsStore, params);
51
94
  }
52
95
 
53
96
  /** @param {Event} event */
@@ -62,6 +105,17 @@ export function onGlobalClick(event) {
62
105
  if (url.origin !== currentOrigin) return;
63
106
 
64
107
  event.preventDefault();
65
- globalThis.history.pushState({}, '', anchor.href);
108
+ const { replace, state } = anchor.dataset;
109
+ const historyMethod = replace === undefined || replace === 'false' ? 'pushState' : 'replaceState';
110
+ globalThis.history[historyMethod](state || {}, '', anchor.href);
66
111
  onNavigate();
67
112
  }
113
+
114
+ function updatedLocation() {
115
+ return {
116
+ pathname: globalThis.location.pathname,
117
+ search: globalThis.location.search,
118
+ state: globalThis.history.state,
119
+ hash: globalThis.location.hash,
120
+ };
121
+ }
package/src/gen/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * @type {{
3
3
  * routesPath: string;
4
+ * routesInJs: boolean;
4
5
  * readonly genCodeDirPath: string;
5
6
  * readonly routerPath: string;
6
7
  * readonly tsconfigPath: string;
@@ -9,8 +10,11 @@
9
10
  */
10
11
  export const genConfig = {
11
12
  routesPath: 'src/routes',
13
+ routesInJs: false,
12
14
  genCodeDirPath: '.router',
13
- routerPath: '.router/router.ts',
15
+ get routerPath() {
16
+ return '.router/router.' + (this.routesInJs ? 'js' : 'ts');
17
+ },
14
18
  tsconfigPath: '.router/tsconfig.json',
15
19
  genCodeAlias: 'sv-router/generated',
16
20
  };
@@ -28,16 +28,41 @@ export function generateRouterCode(routesPath) {
28
28
  export function buildFileTree(routesPath) {
29
29
  const entries = fs.readdirSync(routesPath);
30
30
  /** @type {FileTree} */
31
- const result = [];
31
+ const tree = [];
32
32
  for (const entry of entries) {
33
33
  const stat = fs.lstatSync(path.join(routesPath, entry));
34
34
  if (stat.isDirectory()) {
35
- result.push({ name: entry, tree: buildFileTree(path.join(routesPath, entry)) });
36
- } else if (entry.endsWith('.svelte')) {
37
- result.push(entry);
35
+ tree.push({ name: entry, tree: buildFileTree(path.join(routesPath, entry)) });
36
+ continue;
38
37
  }
38
+ if (!entry.endsWith('.svelte')) continue;
39
+ handleFlatFilename(tree, entry);
39
40
  }
40
- return result;
41
+ return tree;
42
+ }
43
+
44
+ /**
45
+ * @param {FileTree} tree
46
+ * @param {string} path
47
+ */
48
+ function handleFlatFilename(tree, path) {
49
+ // Split the path by the first dot that is not preceded or followed by another dot
50
+ const splited = path.split(/(?<!\.)\.(?!\.)/);
51
+ if (splited.length === 2) {
52
+ tree.push(path);
53
+ return;
54
+ }
55
+ const first = /** @type {string} */ (splited.shift());
56
+ for (const item of tree) {
57
+ if (typeof item === 'object' && item.name === first) {
58
+ handleFlatFilename(item.tree, splited.join('.'));
59
+ return;
60
+ }
61
+ }
62
+ /** @type {FileTree} */
63
+ const branch = [];
64
+ handleFlatFilename(branch, splited.join('.'));
65
+ tree.push({ name: first, tree: branch });
41
66
  }
42
67
 
43
68
  /**
@@ -50,17 +75,18 @@ export function createRouteMap(fileTree, prefix = '') {
50
75
  const result = {};
51
76
  for (const entry of fileTree) {
52
77
  if (typeof entry === 'string') {
53
- switch (entry) {
54
- case 'index.svelte': {
78
+ const catchAll = /\[\.\.\.(.*)\]\.svelte/g.exec(entry); // Match [...slug].svelte
79
+ switch (true) {
80
+ case entry === 'index.svelte': {
55
81
  result['/'] = prefix + entry;
56
82
  break;
57
83
  }
58
- case '*.svelte': {
59
- result['*'] = prefix + entry;
84
+ case entry === 'layout.svelte': {
85
+ result['layout'] = prefix + entry;
60
86
  break;
61
87
  }
62
- case '_layout.svelte': {
63
- result['layout'] = prefix + entry;
88
+ case !!catchAll: {
89
+ result['*' + catchAll[1]] = prefix + entry;
64
90
  break;
65
91
  }
66
92
  default: {
@@ -97,6 +123,6 @@ export function createRouterCode(routes, routesPath) {
97
123
  return [
98
124
  'import { createRouter } from "sv-router";',
99
125
  '\n\n',
100
- `export const { path, goto, params } = createRouter(${withImports});`,
126
+ `export const { p, navigate, isActive, route } = createRouter(${withImports});`,
101
127
  ].join('');
102
128
  }
@@ -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
+ }
@@ -21,8 +21,8 @@ export function matchRoute(pathname, routes) {
21
21
  if (pathname.length > 1 && pathname.endsWith('/')) {
22
22
  pathname = pathname.slice(0, -1);
23
23
  }
24
- const pathParts = pathname.split('/');
25
- const allRouteParts = sortRoutes(Object.keys(routes)).map((route) => route.split('/'));
24
+ const pathParts = pathname.split('/').slice(1);
25
+ const allRoutes = sortRoutes(Object.keys(routes));
26
26
 
27
27
  /** @type {RouteComponent | undefined} */
28
28
  let match;
@@ -35,8 +35,11 @@ export function matchRoute(pathname, routes) {
35
35
 
36
36
  let breakFromLayouts = false;
37
37
 
38
- outer: for (const routeParts of allRouteParts) {
39
- for (let [index, routePart] of sortRoutes(routeParts).entries()) {
38
+ outer: for (const route of allRoutes) {
39
+ const routeParts = route.split('/');
40
+ if (routeParts[0] === '') routeParts.shift();
41
+
42
+ for (let [index, routePart] of routeParts.entries()) {
40
43
  breakFromLayouts = routePart.startsWith('(') && routePart.endsWith(')');
41
44
  if (breakFromLayouts) {
42
45
  routePart = routePart.slice(1, -1);
@@ -45,10 +48,15 @@ export function matchRoute(pathname, routes) {
45
48
  const pathPart = pathParts[index];
46
49
  if (routePart.startsWith(':')) {
47
50
  params[routePart.slice(1)] = pathPart;
48
- } else if (routePart === '*') {
49
- match = /** @type {RouteComponent} */ (
50
- routes[/** @type {keyof Routes} */ (routeParts.join('/'))]
51
+ } else if (routePart.startsWith('*')) {
52
+ const param = routePart.slice(1);
53
+ if (param) {
54
+ params[param] = pathParts.slice(index).join('/');
55
+ }
56
+ const resolvedPath = /** @type {keyof Routes} */ (
57
+ (index ? '/' : '') + routeParts.join('/')
51
58
  );
59
+ match = /** @type {RouteComponent} */ (routes[resolvedPath]);
52
60
  break outer;
53
61
  } else if (routePart !== pathPart) {
54
62
  break;
@@ -58,31 +66,31 @@ export function matchRoute(pathname, routes) {
58
66
  continue;
59
67
  }
60
68
 
69
+ const routeMatch = /** @type {RouteComponent} */ (
70
+ routes[/** @type {keyof Routes} */ ('/' + routeParts.join('/'))]
71
+ );
72
+
61
73
  if (!breakFromLayouts && 'layout' in routes && routes.layout) {
62
74
  layouts.push(routes.layout);
63
75
  }
64
76
 
65
- const routeMatch = /** @type {RouteComponent} */ (
66
- routes[/** @type {keyof Routes} */ (routeParts.join('/'))]
67
- );
68
-
69
77
  if (typeof routeMatch === 'function') {
70
78
  if (routeParts.length === pathParts.length) {
71
79
  match = routeMatch;
72
- } else {
73
- continue;
80
+ break outer;
74
81
  }
75
- } else if (routeMatch) {
76
- const nestedPathname = '/' + pathParts.slice(index + 1).join('/');
77
- const result = matchRoute(nestedPathname, routeMatch);
78
- if (result) {
79
- match = result.match;
80
- params = { ...params, ...result.params };
81
- if (result.breakFromLayouts) {
82
- layouts = [];
83
- } else {
84
- layouts.push(...result.layouts);
85
- }
82
+ continue;
83
+ }
84
+
85
+ const nestedPathname = '/' + pathParts.slice(index + 1).join('/');
86
+ const result = matchRoute(nestedPathname, routeMatch);
87
+ if (result) {
88
+ match = result.match;
89
+ params = { ...params, ...result.params };
90
+ if (result.breakFromLayouts) {
91
+ layouts = [];
92
+ } else {
93
+ layouts.push(...result.layouts);
86
94
  }
87
95
  }
88
96
  break outer;
@@ -106,7 +114,7 @@ export function sortRoutes(routes) {
106
114
  */
107
115
  function getRoutePriority(route) {
108
116
  if (route === '' || route === '/') return 1;
109
- if (route === '*') return 4;
117
+ if (route.startsWith('*')) return 4;
110
118
  if (route.includes(':')) return 3;
111
119
  return 2;
112
120
  }
@@ -0,0 +1,28 @@
1
+ import { matchRoute } from './match-route.js';
2
+ import { resolveRouteComponents } from './utils.js';
3
+
4
+ const linkSet = new Set();
5
+
6
+ /** @param {import('../index.d.ts').Routes} routes */
7
+ export function preloadOnHover(routes) {
8
+ const observer = new MutationObserver(() => {
9
+ const links = document.querySelectorAll('a[data-preload]');
10
+ for (const link of links) {
11
+ if (linkSet.has(link)) continue;
12
+ linkSet.add(link);
13
+
14
+ link.addEventListener('mouseenter', function callback() {
15
+ link.removeEventListener('mouseenter', callback);
16
+ const href = link.getAttribute('href');
17
+ if (!href) return;
18
+ const { match, layouts } = matchRoute(href, routes);
19
+ resolveRouteComponents(match ? [...layouts, match] : layouts);
20
+ });
21
+ }
22
+ });
23
+
24
+ observer.observe(document.body, {
25
+ subtree: true,
26
+ childList: true,
27
+ });
28
+ }
package/src/index.d.ts CHANGED
@@ -1,18 +1,35 @@
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 { path, goto, params } = createRouter({
18
+ * export const { p, navigate, route } = createRouter({
8
19
  * '/': Home,
9
20
  * '/about': About,
10
21
  * ...
11
22
  * });
12
23
  * ```
13
24
  */
14
- export function createRouter<T extends Routes>(r: T): RouterMethods<T>;
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 = {};
@@ -28,14 +45,78 @@ export type LayoutComponent = RouteComponent<{ children: Snippet }>;
28
45
 
29
46
  export type Routes = {
30
47
  [_: `/${string}`]: RouteComponent | Routes;
31
- '*'?: RouteComponent;
48
+ [_: `*${string}`]: RouteComponent | undefined;
32
49
  layout?: LayoutComponent;
33
50
  };
34
51
 
35
- export type RouterMethods<T extends Routes> = {
36
- path<U extends Path<T>>(...args: ConstructPathArgs<U>): string;
37
- goto<U extends Path<T>>(...args: ConstructPathArgs<U>): void;
38
- params(): AllParams<T>;
52
+ export type IsActiveLink = Action<HTMLAnchorElement, { className?: string } | undefined>;
53
+
54
+ export type RouterApi<T extends Routes> = {
55
+ /**
56
+ * Construct a path while ensuring type safety.
57
+ *
58
+ * ```js
59
+ * p('/users');
60
+ * // With parameters
61
+ * p('/users/:id', { id: 1 });
62
+ * ```
63
+ *
64
+ * @param route The route to navigate to.
65
+ * @param params The parameters to replace in the route.
66
+ */
67
+ p<U extends Path<T>>(...args: ConstructPathArgs<U>): string;
68
+ /**
69
+ * Navigate programatically to a route.
70
+ *
71
+ * ```js
72
+ * navigate('/users');
73
+ * // With parameters
74
+ * navigate('/users/:id', {
75
+ * params: {
76
+ * id: 1,
77
+ * },
78
+ * });
79
+ * // Back and forward
80
+ * navigate.back();
81
+ * navigate.forward();
82
+ * ```
83
+ *
84
+ * @param route The route to navigate to.
85
+ * @param options The navigation options.
86
+ */
87
+ navigate: {
88
+ <U extends Path<T>>(...args: NavigateArgs<U>): void;
89
+ back: () => void;
90
+ forward: () => void;
91
+ };
92
+ /**
93
+ * Will return `true` if the given path is active.
94
+ *
95
+ * Can be used with params to check the exact path, or without to check for any params in the
96
+ * path.
97
+ *
98
+ * @param path The route to check.
99
+ * @param params The optional parameters to replace in the route.
100
+ */
101
+ isActive<U extends Path<T>>(...args: IsActiveArgs<U>): boolean;
102
+ route: {
103
+ /**
104
+ * An object containing the parameters of the current route.
105
+ *
106
+ * For example, given the route `/posts/:slug/comments/:commentId` and the URL
107
+ * `http://localhost:5173/posts/hello-world/comments/123`, the `params` object would be `{ slug:
108
+ * 'hello-world', commentId: '123' }`.
109
+ */
110
+ params: AllParams<T>;
111
+ /** The reactive pathname of the URL. */
112
+ pathname: string;
113
+ /** The reactive query string part of the URL. */
114
+ search: string;
115
+ /** The reactive history state that can be passed to the `navigate` function. */
116
+ state: unknown;
117
+ /** The reactive hash part of the URL. */
118
+ hash: string;
119
+ };
39
120
  };
40
121
 
41
122
  export type Path<T extends Routes> = RemoveParenthesis<
@@ -45,15 +126,34 @@ export type Path<T extends Routes> = RemoveParenthesis<
45
126
  export type ConstructPathArgs<T extends string> =
46
127
  PathParams<T> extends never ? [T] : [T, PathParams<T>];
47
128
 
129
+ export type IsActiveArgs<T extends string> =
130
+ PathParams<T> extends never ? [T] : [T] | [T, PathParams<T>];
131
+
48
132
  export type PathParams<T extends string> =
49
133
  ExtractParams<T> extends never ? never : Record<ExtractParams<T>, string>;
50
134
 
51
135
  export type AllParams<T extends Routes> = Partial<Record<ExtractParams<RecursiveKeys<T>>, string>>;
52
136
 
137
+ export type NavigateOptions =
138
+ | {
139
+ replace?: boolean;
140
+ search?: string;
141
+ state?: string;
142
+ hash?: `#${string}`;
143
+ }
144
+ | undefined;
145
+
146
+ type NavigateArgs<T extends string> =
147
+ PathParams<T> extends never
148
+ ? [T, NavigateOptions]
149
+ : [T, NavigateOptions & { params: PathParams<T> }];
150
+
53
151
  type StripNonRoutes<T extends Routes> = {
54
- [K in keyof T as K extends '*' ? never : K extends 'layout' ? never : K]: T[K] extends Routes
55
- ? StripNonRoutes<T[K]>
56
- : T[K];
152
+ [K in keyof T as K extends `*${string}`
153
+ ? never
154
+ : K extends 'layout'
155
+ ? never
156
+ : K]: T[K] extends Routes ? StripNonRoutes<T[K]> : T[K];
57
157
  };
58
158
 
59
159
  type RecursiveKeys<T extends Routes, Prefix extends string = ''> = {
@@ -74,4 +174,8 @@ type ExtractParams<T extends string> = T extends `${string}:${infer Param}/${inf
74
174
  ? Param | ExtractParams<`/${Rest}`>
75
175
  : T extends `${string}:${infer Param}`
76
176
  ? Param
77
- : never;
177
+ : T extends `${string}*${infer Param}`
178
+ ? Param extends ''
179
+ ? never
180
+ : Param
181
+ : 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
+ }
@@ -7,6 +7,12 @@ export type RouterOptions = {
7
7
  * @default 'src/routes'
8
8
  */
9
9
  path?: string;
10
+ /**
11
+ * If true, generates the routes in a .js file instead of a .ts file.
12
+ *
13
+ * @default false
14
+ */
15
+ js?: boolean;
10
16
  };
11
17
 
12
18
  export const router: (options?: RouterOptions) => Plugin;
@@ -7,9 +7,8 @@ import { writeRouterCode } from '../gen/write-router-code.js';
7
7
  * @returns {import('vite').Plugin}
8
8
  */
9
9
  export function router(options) {
10
- if (options?.path) {
11
- genConfig.routesPath = options.path;
12
- }
10
+ if (options?.path) genConfig.routesPath = options.path;
11
+ if (options?.js) genConfig.routesInJs = options.js;
13
12
 
14
13
  return {
15
14
  name: 'sv-router',