sv-router 0.0.5 → 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.5",
3
+ "version": "0.0.6",
4
4
  "description": "Modern Svelte routing",
5
5
  "keywords": [
6
6
  "svelte",
@@ -33,25 +33,25 @@
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"
@@ -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,11 +56,36 @@ export function createRouter(r) {
70
56
  };
71
57
  }
72
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
+
73
80
  export function onNavigate() {
74
81
  if (!routes) {
75
82
  throw new Error('Router not initialized: `createRouter` was not called.');
76
83
  }
77
- location = updatedLocation();
84
+
85
+ syncSearchParams();
86
+
87
+ Object.assign(location, updatedLocation());
88
+
78
89
  const { match, layouts, params: newParams } = matchRoute(globalThis.location.pathname, routes);
79
90
  params.value = newParams || {};
80
91
  resolveRouteComponents(match ? [...layouts, match] : layouts).then((components) => {
@@ -46,7 +46,8 @@ export function buildFileTree(routesPath) {
46
46
  * @param {string} path
47
47
  */
48
48
  function handleFlatFilename(tree, path) {
49
- const splited = path.split('.');
49
+ // Split the path by the first dot that is not preceded or followed by another dot
50
+ const splited = path.split(/(?<!\.)\.(?!\.)/);
50
51
  if (splited.length === 2) {
51
52
  tree.push(path);
52
53
  return;
@@ -74,17 +75,18 @@ export function createRouteMap(fileTree, prefix = '') {
74
75
  const result = {};
75
76
  for (const entry of fileTree) {
76
77
  if (typeof entry === 'string') {
77
- switch (entry) {
78
- case 'index.svelte': {
78
+ const catchAll = /\[\.\.\.(.*)\]\.svelte/g.exec(entry); // Match [...slug].svelte
79
+ switch (true) {
80
+ case entry === 'index.svelte': {
79
81
  result['/'] = prefix + entry;
80
82
  break;
81
83
  }
82
- case '*.svelte': {
83
- result['*'] = prefix + entry;
84
+ case entry === 'layout.svelte': {
85
+ result['layout'] = prefix + entry;
84
86
  break;
85
87
  }
86
- case '_layout.svelte': {
87
- result['layout'] = prefix + entry;
88
+ case !!catchAll: {
89
+ result['*' + catchAll[1]] = prefix + entry;
88
90
  break;
89
91
  }
90
92
  default: {
@@ -121,6 +123,6 @@ export function createRouterCode(routes, routesPath) {
121
123
  return [
122
124
  'import { createRouter } from "sv-router";',
123
125
  '\n\n',
124
- `export const { p, navigate, route } = createRouter(${withImports});`,
126
+ `export const { p, navigate, isActive, route } = createRouter(${withImports});`,
125
127
  ].join('');
126
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
+ }
@@ -48,7 +48,11 @@ export function matchRoute(pathname, routes) {
48
48
  const pathPart = pathParts[index];
49
49
  if (routePart.startsWith(':')) {
50
50
  params[routePart.slice(1)] = pathPart;
51
- } else if (routePart === '*') {
51
+ } else if (routePart.startsWith('*')) {
52
+ const param = routePart.slice(1);
53
+ if (param) {
54
+ params[param] = pathParts.slice(index).join('/');
55
+ }
52
56
  const resolvedPath = /** @type {keyof Routes} */ (
53
57
  (index ? '/' : '') + routeParts.join('/')
54
58
  );
@@ -110,7 +114,7 @@ export function sortRoutes(routes) {
110
114
  */
111
115
  function getRoutePriority(route) {
112
116
  if (route === '' || route === '/') return 1;
113
- if (route === '*') return 4;
117
+ if (route.startsWith('*')) return 4;
114
118
  if (route.includes(':')) return 3;
115
119
  return 2;
116
120
  }
package/src/index.d.ts CHANGED
@@ -1,5 +1,16 @@
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
  *
@@ -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 = {};
@@ -28,10 +45,12 @@ 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
 
52
+ export type IsActiveLink = Action<HTMLAnchorElement, { className?: string } | undefined>;
53
+
35
54
  export type RouterApi<T extends Routes> = {
36
55
  /**
37
56
  * Construct a path while ensuring type safety.
@@ -57,12 +76,29 @@ export type RouterApi<T extends Routes> = {
57
76
  * id: 1,
58
77
  * },
59
78
  * });
79
+ * // Back and forward
80
+ * navigate.back();
81
+ * navigate.forward();
60
82
  * ```
61
83
  *
62
84
  * @param route The route to navigate to.
63
85
  * @param options The navigation options.
64
86
  */
65
- navigate<U extends Path<T>>(...args: NavigateArgs<U>): void;
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;
66
102
  route: {
67
103
  /**
68
104
  * An object containing the parameters of the current route.
@@ -90,6 +126,9 @@ export type Path<T extends Routes> = RemoveParenthesis<
90
126
  export type ConstructPathArgs<T extends string> =
91
127
  PathParams<T> extends never ? [T] : [T, PathParams<T>];
92
128
 
129
+ export type IsActiveArgs<T extends string> =
130
+ PathParams<T> extends never ? [T] : [T] | [T, PathParams<T>];
131
+
93
132
  export type PathParams<T extends string> =
94
133
  ExtractParams<T> extends never ? never : Record<ExtractParams<T>, string>;
95
134
 
@@ -104,15 +143,17 @@ export type NavigateOptions =
104
143
  }
105
144
  | undefined;
106
145
 
107
- export type NavigateArgs<T extends string> =
146
+ type NavigateArgs<T extends string> =
108
147
  PathParams<T> extends never
109
148
  ? [T, NavigateOptions]
110
149
  : [T, NavigateOptions & { params: PathParams<T> }];
111
150
 
112
151
  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];
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];
116
157
  };
117
158
 
118
159
  type RecursiveKeys<T extends Routes, Prefix extends string = ''> = {
@@ -133,4 +174,8 @@ type ExtractParams<T extends string> = T extends `${string}:${infer Param}/${inf
133
174
  ? Param | ExtractParams<`/${Rest}`>
134
175
  : T extends `${string}:${infer Param}`
135
176
  ? Param
136
- : 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
+ }