sv-router 0.0.7 → 0.1.0

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/README.md CHANGED
@@ -1,3 +1,40 @@
1
+ <div align="center">
2
+
3
+ <img src="./docs/public/logo.svg" alt="" height="128px">
4
+
1
5
  # sv-router
2
6
 
3
- https://www.npmjs.com/package/sv-router
7
+ [![npm](https://badgen.net/npm/v/sv-router)](https://www.npmjs.com/package/sv-router)
8
+ [![install size](https://packagephobia.com/badge?p=sv-router)](https://packagephobia.com/result?p=sv-router)
9
+
10
+ A feature-rich yet intuitive routing library for Svelte single-page apps.
11
+
12
+ [Documentation](https://sv-router.vercel.app/) • [Getting Started](https://sv-router.vercel.app/guide/getting-started) • [Reference](https://sv-router.vercel.app/reference)
13
+
14
+ </div>
15
+
16
+ ---
17
+
18
+ ## Features
19
+
20
+ - 🔒 **Typesafe navigation**: Get autocomplete and type checking for your routes.
21
+ - 🔄 **Flexibility**: Choose between code-based or file-based routing approaches.
22
+ - 🌿 **Nested routes**: Create complex layouts with ease.
23
+ - 🔍 **Reactive search params**: For simpler state management in the URL.
24
+ - 🛡️ **Hooks**: For navigation guards, data loading, or analytics tracking.
25
+ - ⚡ **Performance**: Optimized for speed with built-in code splitting and preloading.
26
+ - 🧩 **Familiar API**: Follows established conventions from popular meta frameworks.
27
+ - 🪶 **Lightweight**: Minimal impact on your bundle size.
28
+ - 🚀 **Made for Svelte 5**: True Svelte 5 implementation with the latest features.
29
+
30
+ ## Installation
31
+
32
+ Add it to an existing Svelte project:
33
+
34
+ ```bash
35
+ npm install sv-router
36
+ ```
37
+
38
+ ## License
39
+
40
+ [MIT](./LICENSE) © Colin Lienard
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sv-router",
3
- "version": "0.0.7",
3
+ "version": "0.1.0",
4
4
  "description": "Modern Svelte Routing",
5
5
  "keywords": [
6
6
  "svelte",
@@ -1,14 +1,14 @@
1
1
  import { location } from './create-router.svelte.js';
2
2
 
3
3
  /** @type {import('./index.d.ts').IsActiveLink} */
4
- export function isActiveLink(node, { className = 'is-active' } = {}) {
4
+ export function isActiveLink(node, { className = 'is-active', startsWith = false } = {}) {
5
5
  if (node.tagName !== 'A') {
6
6
  throw new Error('isActiveLink can only be used on <a> elements');
7
7
  }
8
8
 
9
9
  $effect(() => {
10
10
  const pathname = new URL(node.href).pathname;
11
- if (pathname === location.pathname) {
11
+ if (startsWith ? location.pathname.startsWith(pathname) : location.pathname === pathname) {
12
12
  node.classList.add(className);
13
13
  } else {
14
14
  node.classList.remove(className);
@@ -113,6 +113,10 @@ export async function onNavigate(path, options = {}) {
113
113
  syncSearchParams();
114
114
  Object.assign(location, updatedLocation());
115
115
 
116
+ if (options.scrollToTop !== false) {
117
+ window.scrollTo({ top: 0, left: 0, behavior: options.scrollToTop });
118
+ }
119
+
116
120
  for (const { afterLoad } of hooks) {
117
121
  afterLoad?.();
118
122
  }
@@ -130,12 +134,13 @@ export function onGlobalClick(event) {
130
134
  if (url.origin !== currentOrigin) return;
131
135
 
132
136
  event.preventDefault();
133
- const { replace, state } = anchor.dataset;
137
+ const { replace, state, scrollToTop } = anchor.dataset;
134
138
  onNavigate(url.pathname, {
135
139
  replace: replace === '' || replace === 'true',
136
140
  search: url.search,
137
141
  state,
138
142
  hash: url.hash,
143
+ scrollToTop: scrollToTop === 'false' ? false : /** @type ScrollBehavior */ (scrollToTop),
139
144
  });
140
145
  }
141
146
 
@@ -9,18 +9,24 @@ import path from 'node:path';
9
9
  * }} GeneratedRoutes
10
10
  */
11
11
 
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
12
+ const FILENAME_REGEX = /(?<=[/.]|^)\(?([\w-]+)\)?(\.lazy)?\.svelte$/; // any.svelte, any.lazy.svelte, (any).svelte
13
+ const PARAM_FILENAME_REGEX = /(?<=[/.]|^)\(?\[([\w-]+)\]\)?(\.lazy)?\.svelte$/; // [any].svelte, [any].lazy.svelte, ([any]).svelte
14
+ const CATCH_ALL_FILENAME_REGEX = /(?<=[/.]|^)\(?\[\.\.\.([\w-]+)\]\)?(\.lazy)?\.svelte$/; // [...any].svelte, [...any].lazy.svelte, ([...any]).svelte
15
+ const OUT_OF_LAYOUT_FILENAME_REGEX = /(?<=[/.]|^)\(\[\.?\.?\.?([\w-]+)\]\)(\.lazy)?\.svelte$/; // ([any]).svelte, ([...any]).lazy.svelte
16
+ const HOOKS_FILENAME_REGEX = /(?<=[/.]|^)(hooks)(\.svelte)?\.(js|ts)$/; // hooks.js, hooks.svelte.js, hooks.ts, hooks.svelte.ts
15
17
 
16
18
  /**
17
19
  * @param {string} routesPath
18
20
  * @returns {string}
19
21
  */
20
22
  export function generateRouterCode(routesPath) {
21
- const fileTree = buildFileTree(path.join(process.cwd(), routesPath));
23
+ const absoluteRoutesPath = path.join(process.cwd(), routesPath);
24
+ if (!fs.existsSync(absoluteRoutesPath)) {
25
+ throw new Error(`Routes directory not found at \`${routesPath}\``);
26
+ }
27
+ const fileTree = buildFileTree(absoluteRoutesPath);
22
28
  const routeMap = createRouteMap(fileTree);
23
- return createRouterCode(routeMap, path.join('..', routesPath));
29
+ return createRouterCode(routeMap, path.posix.join('..', routesPath));
24
30
  }
25
31
 
26
32
  /**
@@ -74,15 +80,20 @@ export function createRouteMap(fileTree, prefix = '') {
74
80
  continue;
75
81
  }
76
82
 
77
- const catchAll = CATCH_ALL_FILENAME_REGEX.exec(entry);
78
- if (catchAll) {
79
- result['*' + catchAll[1]] = prefix + entry;
83
+ if (CATCH_ALL_FILENAME_REGEX.test(entry)) {
84
+ const replacement = OUT_OF_LAYOUT_FILENAME_REGEX.test(entry) ? '(*$1)' : '*$1';
85
+ let key = filePathToRoute(entry.replace(CATCH_ALL_FILENAME_REGEX, replacement));
86
+ if (!key.startsWith('*') && !key.startsWith('(*')) {
87
+ key = '/' + key;
88
+ }
89
+ result[key] = prefix + entry;
80
90
  continue;
81
91
  }
82
92
 
83
- // Match [id].svelte
84
93
  if (PARAM_FILENAME_REGEX.test(entry)) {
85
- result['/' + filePathToRoute(entry.replace(PARAM_FILENAME_REGEX, ':$1'))] = prefix + entry;
94
+ const replacement = OUT_OF_LAYOUT_FILENAME_REGEX.test(entry) ? '(:$1)' : ':$1';
95
+ const key = '/' + filePathToRoute(entry.replace(PARAM_FILENAME_REGEX, replacement));
96
+ result[key] = prefix + entry;
86
97
  continue;
87
98
  }
88
99
 
@@ -176,7 +187,7 @@ export function pathToCorrectCasing(value) {
176
187
  extractLastPart(CATCH_ALL_FILENAME_REGEX) ||
177
188
  extractLastPart(PARAM_FILENAME_REGEX) ||
178
189
  extractLastPart(HOOKS_FILENAME_REGEX) ||
179
- extractLastPart(/([\w-]+)(\.lazy)?\.svelte$/);
190
+ extractLastPart(FILENAME_REGEX);
180
191
  if (!lastPart) {
181
192
  throw new Error(`Invalid filename: ${value}`);
182
193
  }
@@ -9,10 +9,6 @@ export function writeRouterCode() {
9
9
  fs.mkdirSync(genConfig.genCodeDirPath);
10
10
  }
11
11
 
12
- // Write `.router/router.ts` file
13
- const routerCode = generateRouterCode(genConfig.routesPath);
14
- writeFileIfDifferent(genConfig.routerPath, routerCode);
15
-
16
12
  // Write `.router/tsconfig.json` file
17
13
  const tsConfig = {
18
14
  compilerOptions: {
@@ -35,9 +31,20 @@ export function writeRouterCode() {
35
31
  };
36
32
  writeFileIfDifferent(genConfig.tsconfigPath, JSON.stringify(tsConfig, undefined, 2));
37
33
 
38
- console.log('✅️ Routes generated');
34
+ // Write `.router/router.ts` file
35
+ const routerCode = generateRouterCode(genConfig.routesPath);
36
+ const written = writeFileIfDifferent(genConfig.routerPath, routerCode);
37
+
38
+ if (written) {
39
+ console.log('✅️ Routes generated');
40
+ } else {
41
+ console.log('✅️ Routes already up to date');
42
+ }
39
43
  } catch (error) {
40
- console.error('Error during routes generation:', error);
44
+ console.error(
45
+ 'Error during routes generation:',
46
+ error instanceof Error ? error.message : String(error),
47
+ );
41
48
  }
42
49
  }
43
50
 
@@ -48,5 +55,6 @@ export function writeRouterCode() {
48
55
  function writeFileIfDifferent(filePath, content) {
49
56
  if (!fs.existsSync(filePath) || fs.readFileSync(filePath, 'utf8') !== content) {
50
57
  fs.writeFileSync(filePath, content);
58
+ return true;
51
59
  }
52
60
  }
@@ -7,25 +7,41 @@ import { constructPath } from './utils.js';
7
7
  * @returns {boolean}
8
8
  */
9
9
  export function isActive(pathname, params) {
10
+ return compare((a, b) => a === b, pathname, params);
11
+ }
12
+
13
+ /**
14
+ * @param {string} pathname
15
+ * @param {Record<string, string>} [params]
16
+ * @returns {boolean}
17
+ */
18
+ isActive.startsWith = (pathname, params) => {
19
+ return compare((a, b) => a.startsWith(b), pathname, params);
20
+ };
21
+
22
+ /**
23
+ * @param {function(string, string): boolean} compareFn
24
+ * @param {string} pathname
25
+ * @param {Record<string, string>} [params]
26
+ * @returns {boolean}
27
+ */
28
+ function compare(compareFn, pathname, params) {
10
29
  if (!pathname.includes(':')) {
11
- return pathname === location.pathname;
30
+ return compareFn(location.pathname, pathname);
12
31
  }
13
32
 
14
33
  if (params) {
15
- return constructPath(pathname, params) === location.pathname;
34
+ return compareFn(location.pathname, constructPath(pathname, params));
16
35
  }
17
36
 
18
37
  const pathParts = pathname.split('/').slice(1);
19
38
  const routeParts = location.pathname.split('/').slice(1);
20
- if (pathParts.length !== routeParts.length) {
21
- return false;
22
- }
23
39
  for (const [index, pathPart] of pathParts.entries()) {
24
40
  const routePart = routeParts[index];
25
41
  if (routePart.startsWith(':')) {
26
42
  continue;
27
43
  }
28
- return pathPart === routePart;
44
+ return compareFn(pathPart, routePart);
29
45
  }
30
46
  return false;
31
47
  }
@@ -59,6 +59,9 @@ export function matchRoute(pathname, routes) {
59
59
  if (param) {
60
60
  params[param] = pathParts.slice(index).join('/');
61
61
  }
62
+ if (breakFromLayouts) {
63
+ routePart = `(${routePart})`;
64
+ }
62
65
  const resolvedPath = /** @type {keyof Routes} */ (
63
66
  (index ? '/' : '') + routeParts.join('/')
64
67
  );
package/src/index.d.ts CHANGED
@@ -29,7 +29,7 @@ export const Router: Component;
29
29
  * The reactive search params of the URL. It is just a wrapper around `SvelteURLSearchParam` that
30
30
  * will update the url on change.
31
31
  */
32
- export const searchParams: URLSearchParams;
32
+ export const searchParams: SearchParams;
33
33
 
34
34
  // eslint-disable-next-line @typescript-eslint/no-empty-object-type
35
35
  type BaseProps = {};
@@ -63,12 +63,15 @@ export type Hooks = {
63
63
 
64
64
  export type Routes = {
65
65
  [_: `/${string}`]: RouteComponent | Routes;
66
- [_: `*${string}`]: RouteComponent | undefined;
66
+ [_: `*${string}` | `(*${string})`]: RouteComponent | undefined;
67
67
  layout?: LayoutComponent;
68
68
  hooks?: Hooks;
69
69
  };
70
70
 
71
- export type IsActiveLink = Action<HTMLAnchorElement, { className?: string } | undefined>;
71
+ export type IsActiveLink = Action<
72
+ HTMLAnchorElement,
73
+ { className?: string; startsWith?: boolean } | undefined
74
+ >;
72
75
 
73
76
  export type RouterApi<T extends Routes> = {
74
77
  /**
@@ -114,7 +117,10 @@ export type RouterApi<T extends Routes> = {
114
117
  * @param path The route to check.
115
118
  * @param params The optional parameters to replace in the route.
116
119
  */
117
- isActive<U extends Path<T>>(...args: IsActiveArgs<U>): boolean;
120
+ isActive: {
121
+ <U extends Path<T>>(...args: IsActiveArgs<U>): boolean;
122
+ startsWith<U extends Path<T>>(...args: IsActiveArgs<U>): boolean;
123
+ };
118
124
  route: {
119
125
  /**
120
126
  * An object containing the parameters of the current route.
@@ -156,9 +162,17 @@ export type NavigateOptions =
156
162
  search?: string;
157
163
  state?: string;
158
164
  hash?: string;
165
+ scrollToTop?: ScrollBehavior | false;
159
166
  }
160
167
  | undefined;
161
168
 
169
+ export type SearchParams = URLSearchParams & {
170
+ append: (name: string, value: string, options?: { replace?: boolean }) => void;
171
+ delete: (name: string, value?: string, options?: { replace?: boolean }) => void;
172
+ set: (name: string, value: string, options?: { replace?: boolean }) => void;
173
+ sort: (options?: { replace?: boolean }) => void;
174
+ };
175
+
162
176
  type NavigateArgs<T extends string> =
163
177
  | (PathParams<T> extends never
164
178
  ? [T] | [T, NavigateOptions]
@@ -168,11 +182,13 @@ type NavigateArgs<T extends string> =
168
182
  type StripNonRoutes<T extends Routes> = {
169
183
  [K in keyof T as K extends `*${string}`
170
184
  ? never
171
- : K extends 'layout'
185
+ : K extends `(*${string})`
172
186
  ? never
173
- : K extends 'hooks'
187
+ : K extends 'layout'
174
188
  ? never
175
- : K]: T[K] extends Routes ? StripNonRoutes<T[K]> : T[K];
189
+ : K extends 'hooks'
190
+ ? never
191
+ : K]: T[K] extends Routes ? StripNonRoutes<T[K]> : T[K];
176
192
  };
177
193
 
178
194
  type RecursiveKeys<T extends Routes, Prefix extends string = ''> = {
@@ -191,10 +207,14 @@ type RemoveParenthesis<T extends string> = T extends `${infer A}(${infer B})${in
191
207
 
192
208
  type ExtractParams<T extends string> = T extends `${string}:${infer Param}/${infer Rest}`
193
209
  ? Param | ExtractParams<`/${Rest}`>
194
- : T extends `${string}:${infer Param}`
210
+ : T extends `${string}(:${infer Param})`
195
211
  ? Param
196
- : T extends `${string}*${infer Param}`
197
- ? Param extends ''
198
- ? never
199
- : Param
200
- : never;
212
+ : T extends `${string}:${infer Param}`
213
+ ? Param
214
+ : T extends `${string}(*${infer Param})`
215
+ ? Param
216
+ : T extends `${string}*${infer Param}`
217
+ ? Param extends ''
218
+ ? never
219
+ : Param
220
+ : never;
@@ -2,15 +2,15 @@ import { SvelteURLSearchParams } from 'svelte/reactivity';
2
2
 
3
3
  let searchParams = new SvelteURLSearchParams(globalThis.location.search);
4
4
 
5
- /** @type {URLSearchParams} */
5
+ /** @type {import('./index.js').SearchParams} */
6
6
  const shell = {
7
- append(...args) {
8
- searchParams.append(...args);
9
- updateUrlSearchParams();
7
+ append(name, value, options) {
8
+ searchParams.append(name, value);
9
+ updateUrlSearchParams(options);
10
10
  },
11
- delete(...args) {
12
- searchParams.delete(...args);
13
- updateUrlSearchParams();
11
+ delete(name, value, options) {
12
+ searchParams.delete(name, value);
13
+ updateUrlSearchParams(options);
14
14
  },
15
15
  entries() {
16
16
  return searchParams.entries();
@@ -31,13 +31,13 @@ const shell = {
31
31
  keys() {
32
32
  return searchParams.keys();
33
33
  },
34
- set(...args) {
35
- searchParams.set(...args);
36
- updateUrlSearchParams();
34
+ set(name, value, options) {
35
+ searchParams.set(name, value);
36
+ updateUrlSearchParams(options);
37
37
  },
38
- sort() {
38
+ sort(options) {
39
39
  searchParams.sort();
40
- updateUrlSearchParams();
40
+ updateUrlSearchParams(options);
41
41
  },
42
42
  toString() {
43
43
  return searchParams.toString();
@@ -60,19 +60,17 @@ export function syncSearchParams() {
60
60
  if (searchParams.toString() === newSearchParams.toString()) {
61
61
  return;
62
62
  }
63
-
64
- for (const key of searchParams.keys()) {
65
- searchParams.delete(key);
66
- }
63
+ searchParams = new SvelteURLSearchParams();
67
64
  for (const [key, value] of newSearchParams.entries()) {
68
65
  searchParams.append(key, value);
69
66
  }
70
67
  }
71
68
 
72
- function updateUrlSearchParams() {
69
+ /** @param {{ replace?: boolean }} [options] */
70
+ function updateUrlSearchParams(options) {
73
71
  let url = globalThis.location.origin + globalThis.location.pathname;
74
72
  if (searchParams.size > 0) {
75
73
  url += '?' + searchParams.toString();
76
74
  }
77
- globalThis.history.pushState({}, '', url);
75
+ globalThis.history[options?.replace ? 'replaceState' : 'pushState']({}, '', url);
78
76
  }