sv-router 0.0.6 → 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.6",
4
- "description": "Modern Svelte routing",
3
+ "version": "0.0.7",
4
+ "description": "Modern Svelte Routing",
5
5
  "keywords": [
6
6
  "svelte",
7
7
  "router",
@@ -36,31 +36,34 @@
36
36
  "esm-env": "^1.2.2"
37
37
  },
38
38
  "devDependencies": {
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",
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.14.0",
47
- "prettier": "^3.4.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",
48
49
  "prettier-plugin-jsdoc": "^1.3.2",
49
50
  "prettier-plugin-svelte": "^3.3.3",
51
+ "svelte-check": "^4.1.4",
50
52
  "type-testing": "^0.2.0",
51
- "typescript": "^5.7.3",
52
- "typescript-eslint": "^8.20.0",
53
- "vite": "^6.0.7",
54
- "vitest": "^3.0.2"
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 () => {
@@ -57,40 +57,65 @@ export function createRouter(r) {
57
57
  }
58
58
 
59
59
  /**
60
- * @param {string} path
60
+ * @param {string | number} path
61
61
  * @param {import('./index.d.ts').NavigateOptions & { params?: Record<string, string> }} options
62
62
  */
63
63
  function navigate(path, options = {}) {
64
+ if (typeof path === 'number') {
65
+ globalThis.history.go(path);
66
+ return;
67
+ }
64
68
  if (options.params) {
65
69
  path = constructPath(path, options.params);
66
70
  }
67
- if (options.search) {
68
- path += (options.search.startsWith('?') ? '' : '?') + options.search;
71
+ if (options.search && !options.search.startsWith('?')) {
72
+ options.search = '?' + options.search;
69
73
  }
70
- if (options.hash) {
71
- path += options.hash;
74
+ if (options.hash && !options.hash.startsWith('#')) {
75
+ options.hash = '#' + options.hash;
72
76
  }
73
- const historyMethod = options.replace ? 'replaceState' : 'pushState';
74
- globalThis.history[historyMethod](options.state || {}, '', path);
75
- onNavigate();
77
+ onNavigate(path, options);
76
78
  }
77
- navigate.back = () => globalThis.history.back();
78
- navigate.forward = () => globalThis.history.forward();
79
79
 
80
- export function onNavigate() {
80
+ /**
81
+ * @param {string} [path]
82
+ * @param {import('./index.d.ts').NavigateOptions} options
83
+ */
84
+ export async function onNavigate(path, options = {}) {
81
85
  if (!routes) {
82
86
  throw new Error('Router not initialized: `createRouter` was not called.');
83
87
  }
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
+ }
84
102
 
85
- syncSearchParams();
103
+ componentTree.value = await resolveRouteComponents(match ? [...layouts, match] : layouts);
104
+ params.value = newParams || {};
86
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();
87
114
  Object.assign(location, updatedLocation());
88
115
 
89
- const { match, layouts, params: newParams } = matchRoute(globalThis.location.pathname, routes);
90
- params.value = newParams || {};
91
- resolveRouteComponents(match ? [...layouts, match] : layouts).then((components) => {
92
- componentTree.value = components;
93
- });
116
+ for (const { afterLoad } of hooks) {
117
+ afterLoad?.();
118
+ }
94
119
  }
95
120
 
96
121
  /** @param {Event} event */
@@ -106,9 +131,12 @@ export function onGlobalClick(event) {
106
131
 
107
132
  event.preventDefault();
108
133
  const { replace, state } = anchor.dataset;
109
- const historyMethod = replace === undefined || replace === 'false' ? 'pushState' : 'replaceState';
110
- globalThis.history[historyMethod](state || {}, '', anchor.href);
111
- onNavigate();
134
+ onNavigate(url.pathname, {
135
+ replace: replace === '' || replace === 'true',
136
+ search: url.search,
137
+ state,
138
+ hash: url.hash,
139
+ });
112
140
  }
113
141
 
114
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,34 +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
- // 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;
40
+ if (!entry.endsWith('.svelte') && !HOOKS_FILENAME_REGEX.test(entry)) {
41
+ continue;
60
42
  }
43
+ tree.push(entry);
61
44
  }
62
- /** @type {FileTree} */
63
- const branch = [];
64
- handleFlatFilename(branch, splited.join('.'));
65
- tree.push({ name: first, tree: branch });
45
+ return tree;
66
46
  }
67
47
 
68
48
  /**
@@ -75,36 +55,56 @@ export function createRouteMap(fileTree, prefix = '') {
75
55
  const result = {};
76
56
  for (const entry of fileTree) {
77
57
  if (typeof entry === 'string') {
78
- const catchAll = /\[\.\.\.(.*)\]\.svelte/g.exec(entry); // Match [...slug].svelte
79
- switch (true) {
80
- case entry === 'index.svelte': {
81
- result['/'] = prefix + entry;
82
- break;
83
- }
84
- case entry === 'layout.svelte': {
85
- result['layout'] = prefix + entry;
86
- break;
87
- }
88
- case !!catchAll: {
89
- result['*' + catchAll[1]] = prefix + entry;
90
- break;
91
- }
92
- default: {
93
- if (PARAM_FILENAME_REGEX.test(entry)) {
94
- result['/' + entry.replaceAll(PARAM_FILENAME_REGEX, ':$1')] = prefix + entry;
95
- break;
96
- }
97
- result['/' + entry.replace('.svelte', '')] = prefix + entry;
98
- break;
58
+ if (!entry.endsWith('.svelte')) {
59
+ if (HOOKS_FILENAME_REGEX.test(entry)) {
60
+ result['hooks'] = prefix + entry;
61
+ continue;
99
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;
100
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;
101
90
  } else {
102
- result['/' + entry.name] = createRouteMap(entry.tree, prefix + entry.name + '/');
91
+ const entryName = filePathToRoute(entry.name);
92
+ result['/' + entryName] = createRouteMap(entry.tree, prefix + entryName + '/');
103
93
  }
104
94
  }
105
95
  return result;
106
96
  }
107
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
+
108
108
  /**
109
109
  * @param {GeneratedRoutes} routes
110
110
  * @param {string} routesPath
@@ -115,14 +115,76 @@ export function createRouterCode(routes, routesPath) {
115
115
  routesPath += '/';
116
116
  }
117
117
 
118
- const jsonRoutes = JSON.stringify(routes, undefined, 2);
119
- const withImports = jsonRoutes.replaceAll(
120
- /"(.*)": "(.*)",?/g,
121
- `"$1": () => import("${routesPath}$2"),`,
122
- );
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
+
123
149
  return [
124
- 'import { createRouter } from "sv-router";',
125
- '\n\n',
126
- `export const { p, navigate, isActive, route } = createRouter(${withImports});`,
127
- ].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('');
128
190
  }
@@ -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
 
@@ -74,6 +80,10 @@ export function matchRoute(pathname, routes) {
74
80
  layouts.push(routes.layout);
75
81
  }
76
82
 
83
+ if ('hooks' in routes && routes.hooks) {
84
+ hooks.push(routes.hooks);
85
+ }
86
+
77
87
  if (typeof routeMatch === 'function') {
78
88
  if (routeParts.length === pathParts.length) {
79
89
  match = routeMatch;
@@ -87,6 +97,7 @@ export function matchRoute(pathname, routes) {
87
97
  if (result) {
88
98
  match = result.match;
89
99
  params = { ...params, ...result.params };
100
+ hooks.push(...result.hooks);
90
101
  if (result.breakFromLayouts) {
91
102
  layouts = [];
92
103
  } else {
@@ -97,7 +108,7 @@ export function matchRoute(pathname, routes) {
97
108
  }
98
109
  }
99
110
 
100
- return { match, layouts, params, breakFromLayouts };
111
+ return { match, layouts, hooks, params, breakFromLayouts };
101
112
  }
102
113
 
103
114
  /**
@@ -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
@@ -15,7 +15,7 @@ export const isActiveLink: IsActiveLink;
15
15
  * Setup a new router instance with the given routes.
16
16
  *
17
17
  * ```js
18
- * export const { p, navigate, route } = createRouter({
18
+ * export const { p, navigate, isActive, route } = createRouter({
19
19
  * '/': Home,
20
20
  * '/about': About,
21
21
  * ...
@@ -42,11 +42,30 @@ export type RouteComponent<Props extends BaseProps = any> =
42
42
  | Component<Props>
43
43
  | LazyRouteComponent<Props>;
44
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
+ };
45
63
 
46
64
  export type Routes = {
47
65
  [_: `/${string}`]: RouteComponent | Routes;
48
66
  [_: `*${string}`]: RouteComponent | undefined;
49
67
  layout?: LayoutComponent;
68
+ hooks?: Hooks;
50
69
  };
51
70
 
52
71
  export type IsActiveLink = Action<HTMLAnchorElement, { className?: string } | undefined>;
@@ -77,18 +96,15 @@ export type RouterApi<T extends Routes> = {
77
96
  * },
78
97
  * });
79
98
  * // Back and forward
80
- * navigate.back();
81
- * navigate.forward();
99
+ * navigate(-1);
100
+ * navigate(2);
82
101
  * ```
83
102
  *
84
103
  * @param route The route to navigate to.
85
104
  * @param options The navigation options.
86
105
  */
87
- navigate: {
88
- <U extends Path<T>>(...args: NavigateArgs<U>): void;
89
- back: () => void;
90
- forward: () => void;
91
- };
106
+ navigate<U extends Path<T>>(...args: NavigateArgs<U>): void;
107
+
92
108
  /**
93
109
  * Will return `true` if the given path is active.
94
110
  *
@@ -139,21 +155,24 @@ export type NavigateOptions =
139
155
  replace?: boolean;
140
156
  search?: string;
141
157
  state?: string;
142
- hash?: `#${string}`;
158
+ hash?: string;
143
159
  }
144
160
  | undefined;
145
161
 
146
162
  type NavigateArgs<T extends string> =
147
- PathParams<T> extends never
148
- ? [T, NavigateOptions]
149
- : [T, NavigateOptions & { params: PathParams<T> }];
163
+ | (PathParams<T> extends never
164
+ ? [T] | [T, NavigateOptions]
165
+ : [T, NavigateOptions & { params: PathParams<T> }])
166
+ | [number];
150
167
 
151
168
  type StripNonRoutes<T extends Routes> = {
152
169
  [K in keyof T as K extends `*${string}`
153
170
  ? never
154
171
  : K extends 'layout'
155
172
  ? never
156
- : K]: T[K] extends Routes ? StripNonRoutes<T[K]> : T[K];
173
+ : K extends 'hooks'
174
+ ? never
175
+ : K]: T[K] extends Routes ? StripNonRoutes<T[K]> : T[K];
157
176
  };
158
177
 
159
178
  type RecursiveKeys<T extends Routes, Prefix extends string = ''> = {