sv-router 0.0.4 → 0.0.5

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.5",
4
4
  "description": "Modern Svelte routing",
5
5
  "keywords": [
6
6
  "svelte",
@@ -57,7 +57,7 @@
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>
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,23 @@
1
1
  import { BROWSER, DEV } from 'esm-env';
2
2
  import { matchRoute } from './helpers/match-route.js';
3
+ import { preloadOnHover } from './helpers/preload-on-hover.js';
3
4
  import { constructPath, resolveRouteComponents } from './helpers/utils.js';
4
5
 
5
6
  /** @type {import('./index.d.ts').Routes} */
6
7
  export let routes;
7
8
 
8
- /** @type {import('svelte').Component[]} */
9
- export const componentTree = $state([]);
9
+ /** @type {{ value: import('svelte').Component[] }} */
10
+ export let componentTree = $state({ value: [] });
10
11
 
11
- /** @type {Record<string, string>} */
12
- export const paramsStore = $state({});
12
+ /** @type {{ value: Record<string, string> }} */
13
+ export let params = $state({ value: {} });
14
+
15
+ let location = $state(updatedLocation());
13
16
 
14
17
  /**
15
18
  * @template {import('./index.d.ts').Routes} T
16
19
  * @param {T} r
17
- * @returns {import('./index.d.ts').RouterMethods<T>}
20
+ * @returns {import('./index.d.ts').RouterApi<T>}
18
21
  */
19
22
  export function createRouter(r) {
20
23
  routes = r;
@@ -25,16 +28,44 @@ export function createRouter(r) {
25
28
  });
26
29
  }
27
30
 
31
+ preloadOnHover(routes);
32
+
28
33
  return {
29
- path: constructPath,
30
- goto(...args) {
31
- const path = constructPath(args[0], args[1]);
32
- globalThis.history.pushState({}, '', path);
34
+ 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);
33
51
  onNavigate();
34
52
  },
35
- params() {
36
- const readonly = $derived(paramsStore);
37
- return readonly;
53
+ route: {
54
+ get params() {
55
+ return params.value;
56
+ },
57
+ get pathname() {
58
+ return location.pathname;
59
+ },
60
+ get search() {
61
+ return location.search;
62
+ },
63
+ get state() {
64
+ return location.state;
65
+ },
66
+ get hash() {
67
+ return location.hash;
68
+ },
38
69
  },
39
70
  };
40
71
  }
@@ -43,11 +74,12 @@ export function onNavigate() {
43
74
  if (!routes) {
44
75
  throw new Error('Router not initialized: `createRouter` was not called.');
45
76
  }
46
- const { match, layouts, params } = matchRoute(globalThis.location.pathname, routes);
77
+ location = updatedLocation();
78
+ const { match, layouts, params: newParams } = matchRoute(globalThis.location.pathname, routes);
79
+ params.value = newParams || {};
47
80
  resolveRouteComponents(match ? [...layouts, match] : layouts).then((components) => {
48
- Object.assign(componentTree, components);
81
+ componentTree.value = components;
49
82
  });
50
- Object.assign(paramsStore, params);
51
83
  }
52
84
 
53
85
  /** @param {Event} event */
@@ -62,6 +94,17 @@ export function onGlobalClick(event) {
62
94
  if (url.origin !== currentOrigin) return;
63
95
 
64
96
  event.preventDefault();
65
- globalThis.history.pushState({}, '', anchor.href);
97
+ const { replace, state } = anchor.dataset;
98
+ const historyMethod = replace === undefined || replace === 'false' ? 'pushState' : 'replaceState';
99
+ globalThis.history[historyMethod](state || {}, '', anchor.href);
66
100
  onNavigate();
67
101
  }
102
+
103
+ function updatedLocation() {
104
+ return {
105
+ pathname: globalThis.location.pathname,
106
+ search: globalThis.location.search,
107
+ state: globalThis.history.state,
108
+ hash: globalThis.location.hash,
109
+ };
110
+ }
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,40 @@ 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
+ 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;
59
+ }
60
+ }
61
+ /** @type {FileTree} */
62
+ const branch = [];
63
+ handleFlatFilename(branch, splited.join('.'));
64
+ tree.push({ name: first, tree: branch });
41
65
  }
42
66
 
43
67
  /**
@@ -97,6 +121,6 @@ export function createRouterCode(routes, routesPath) {
97
121
  return [
98
122
  'import { createRouter } from "sv-router";',
99
123
  '\n\n',
100
- `export const { path, goto, params } = createRouter(${withImports});`,
124
+ `export const { p, navigate, route } = createRouter(${withImports});`,
101
125
  ].join('');
102
126
  }
@@ -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);
@@ -46,9 +49,10 @@ export function matchRoute(pathname, routes) {
46
49
  if (routePart.startsWith(':')) {
47
50
  params[routePart.slice(1)] = pathPart;
48
51
  } else if (routePart === '*') {
49
- match = /** @type {RouteComponent} */ (
50
- routes[/** @type {keyof Routes} */ (routeParts.join('/'))]
52
+ const resolvedPath = /** @type {keyof Routes} */ (
53
+ (index ? '/' : '') + routeParts.join('/')
51
54
  );
55
+ match = /** @type {RouteComponent} */ (routes[resolvedPath]);
52
56
  break outer;
53
57
  } else if (routePart !== pathPart) {
54
58
  break;
@@ -58,31 +62,31 @@ export function matchRoute(pathname, routes) {
58
62
  continue;
59
63
  }
60
64
 
65
+ const routeMatch = /** @type {RouteComponent} */ (
66
+ routes[/** @type {keyof Routes} */ ('/' + routeParts.join('/'))]
67
+ );
68
+
61
69
  if (!breakFromLayouts && 'layout' in routes && routes.layout) {
62
70
  layouts.push(routes.layout);
63
71
  }
64
72
 
65
- const routeMatch = /** @type {RouteComponent} */ (
66
- routes[/** @type {keyof Routes} */ (routeParts.join('/'))]
67
- );
68
-
69
73
  if (typeof routeMatch === 'function') {
70
74
  if (routeParts.length === pathParts.length) {
71
75
  match = routeMatch;
72
- } else {
73
- continue;
76
+ break outer;
74
77
  }
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
- }
78
+ continue;
79
+ }
80
+
81
+ const nestedPathname = '/' + pathParts.slice(index + 1).join('/');
82
+ const result = matchRoute(nestedPathname, routeMatch);
83
+ if (result) {
84
+ match = result.match;
85
+ params = { ...params, ...result.params };
86
+ if (result.breakFromLayouts) {
87
+ layouts = [];
88
+ } else {
89
+ layouts.push(...result.layouts);
86
90
  }
87
91
  }
88
92
  break outer;
@@ -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
@@ -4,14 +4,14 @@ import type { Component, Snippet } from 'svelte';
4
4
  * Setup a new router instance with the given routes.
5
5
  *
6
6
  * ```js
7
- * export const { path, goto, params } = createRouter({
7
+ * export const { p, navigate, route } = createRouter({
8
8
  * '/': Home,
9
9
  * '/about': About,
10
10
  * ...
11
11
  * });
12
12
  * ```
13
13
  */
14
- export function createRouter<T extends Routes>(r: T): RouterMethods<T>;
14
+ export function createRouter<T extends Routes>(r: T): RouterApi<T>;
15
15
  export const Router: Component;
16
16
 
17
17
  // eslint-disable-next-line @typescript-eslint/no-empty-object-type
@@ -32,10 +32,55 @@ export type Routes = {
32
32
  layout?: LayoutComponent;
33
33
  };
34
34
 
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>;
35
+ export type RouterApi<T extends Routes> = {
36
+ /**
37
+ * Construct a path while ensuring type safety.
38
+ *
39
+ * ```js
40
+ * p('/users');
41
+ * // With parameters
42
+ * p('/users/:id', { id: 1 });
43
+ * ```
44
+ *
45
+ * @param route The route to navigate to.
46
+ * @param params The parameters to replace in the route.
47
+ */
48
+ p<U extends Path<T>>(...args: ConstructPathArgs<U>): string;
49
+ /**
50
+ * Navigate programatically to a route.
51
+ *
52
+ * ```js
53
+ * navigate('/users');
54
+ * // With parameters
55
+ * navigate('/users/:id', {
56
+ * params: {
57
+ * id: 1,
58
+ * },
59
+ * });
60
+ * ```
61
+ *
62
+ * @param route The route to navigate to.
63
+ * @param options The navigation options.
64
+ */
65
+ navigate<U extends Path<T>>(...args: NavigateArgs<U>): void;
66
+ route: {
67
+ /**
68
+ * An object containing the parameters of the current route.
69
+ *
70
+ * For example, given the route `/posts/:slug/comments/:commentId` and the URL
71
+ * `http://localhost:5173/posts/hello-world/comments/123`, the `params` object would be `{ slug:
72
+ * 'hello-world', commentId: '123' }`.
73
+ */
74
+ params: AllParams<T>;
75
+ /** The reactive pathname of the URL. */
76
+ pathname: string;
77
+ /** The reactive query string part of the URL. */
78
+ search: string;
79
+ /** The reactive history state that can be passed to the `navigate` function. */
80
+ state: unknown;
81
+ /** The reactive hash part of the URL. */
82
+ hash: string;
83
+ };
39
84
  };
40
85
 
41
86
  export type Path<T extends Routes> = RemoveParenthesis<
@@ -50,6 +95,20 @@ export type PathParams<T extends string> =
50
95
 
51
96
  export type AllParams<T extends Routes> = Partial<Record<ExtractParams<RecursiveKeys<T>>, string>>;
52
97
 
98
+ export type NavigateOptions =
99
+ | {
100
+ replace?: boolean;
101
+ search?: string;
102
+ state?: string;
103
+ hash?: `#${string}`;
104
+ }
105
+ | undefined;
106
+
107
+ export type NavigateArgs<T extends string> =
108
+ PathParams<T> extends never
109
+ ? [T, NavigateOptions]
110
+ : [T, NavigateOptions & { params: PathParams<T> }];
111
+
53
112
  type StripNonRoutes<T extends Routes> = {
54
113
  [K in keyof T as K extends '*' ? never : K extends 'layout' ? never : K]: T[K] extends Routes
55
114
  ? StripNonRoutes<T[K]>
@@ -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',