sv-router 0.0.1 → 0.0.3

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.
Files changed (37) hide show
  1. package/package.json +69 -66
  2. package/{dist → src}/RecursiveComponentTree.svelte +4 -4
  3. package/{dist → src}/Router.svelte +2 -2
  4. package/src/cli/index.js +5 -0
  5. package/src/create-router.svelte.js +67 -0
  6. package/src/gen/config.js +16 -0
  7. package/src/gen/generate-router-code.js +102 -0
  8. package/src/gen/write-router-code.js +52 -0
  9. package/src/helpers/match-route.js +112 -0
  10. package/src/helpers/utils.js +46 -0
  11. package/src/helpers/validate-routes.js +46 -0
  12. package/src/index.d.ts +77 -0
  13. package/src/index.js +2 -0
  14. package/src/vite-plugin/index.d.ts +12 -0
  15. package/src/vite-plugin/index.js +1 -0
  16. package/src/vite-plugin/plugin.js +42 -0
  17. package/dist/RecursiveComponentTree.svelte.d.ts +0 -8
  18. package/dist/helpers/construct-path.d.ts +0 -3
  19. package/dist/helpers/construct-path.js +0 -10
  20. package/dist/helpers/match-route.d.ts +0 -7
  21. package/dist/helpers/match-route.js +0 -65
  22. package/dist/helpers/match-route.test.d.ts +0 -1
  23. package/dist/helpers/match-route.test.js +0 -133
  24. package/dist/helpers/utils.d.ts +0 -5
  25. package/dist/helpers/utils.js +0 -18
  26. package/dist/helpers/validate-routes.d.ts +0 -3
  27. package/dist/helpers/validate-routes.js +0 -34
  28. package/dist/helpers/validate-routes.test.d.ts +0 -1
  29. package/dist/helpers/validate-routes.test.js +0 -67
  30. package/dist/index.d.ts +0 -2
  31. package/dist/index.js +0 -2
  32. package/dist/router.svelte.d.ts +0 -13
  33. package/dist/router.svelte.js +0 -50
  34. package/dist/types/test-types.d.ts +0 -1
  35. package/dist/types/test-types.js +0 -1
  36. package/dist/types/types.d.ts +0 -26
  37. package/dist/types/types.js +0 -1
package/package.json CHANGED
@@ -1,67 +1,70 @@
1
1
  {
2
- "name": "sv-router",
3
- "version": "0.0.1",
4
- "description": "Modern Svelte routing",
5
- "keywords": [
6
- "svelte",
7
- "router",
8
- "spa"
9
- ],
10
- "homepage": "https://github.com/colinlienard/sv-router",
11
- "bugs": {
12
- "url": "https://github.com/colinlienard/sv-router/issues"
13
- },
14
- "repository": {
15
- "type": "git",
16
- "url": "git+https://github.com/colinlienard/sv-router.git"
17
- },
18
- "license": "MIT",
19
- "type": "module",
20
- "exports": {
21
- ".": {
22
- "sv-router/source": "./src/index.ts",
23
- "import": "./dist/index.js",
24
- "svelte": "./dist/index.js",
25
- "types": "./dist/index.d.ts"
26
- }
27
- },
28
- "files": [
29
- "dist"
30
- ],
31
- "scripts": {
32
- "ex:basic": "pnpm --filter basic-example",
33
- "build": "svelte-package -i src && rm -rf .svelte-kit",
34
- "test": "vitest",
35
- "check": "tsc --noEmit && pnpm -r check",
36
- "lint": "eslint .",
37
- "lint:fix": "eslint . --fix",
38
- "format": "prettier . --check",
39
- "format:fix": "prettier . --write",
40
- "sherif": "sherif -r root-package-private-field",
41
- "sherif:fix": "sherif -r root-package-private-field --fix"
42
- },
43
- "dependencies": {
44
- "esm-env": "^1.1.4"
45
- },
46
- "devDependencies": {
47
- "@eslint/js": "^9.15.0",
48
- "@sveltejs/package": "^2.3.7",
49
- "eslint-config-prettier": "^9.1.0",
50
- "eslint-plugin-simple-import-sort": "^12.1.1",
51
- "eslint-plugin-svelte": "^2.46.0",
52
- "eslint-plugin-unicorn": "^56.0.1",
53
- "globals": "^15.12.0",
54
- "prettier": "^3.3.3",
55
- "prettier-plugin-svelte": "^3.3.2",
56
- "sherif": "^1.0.1",
57
- "type-testing": "^0.2.0",
58
- "typescript": "^5.7.2",
59
- "typescript-eslint": "^8.14.0",
60
- "vite": "^5.4.10",
61
- "vitest": "^2.1.5"
62
- },
63
- "peerDependencies": {
64
- "svelte": "^5"
65
- },
66
- "packageManager": "pnpm@9.14.2"
67
- }
2
+ "name": "sv-router",
3
+ "version": "0.0.3",
4
+ "description": "Modern Svelte routing",
5
+ "keywords": [
6
+ "svelte",
7
+ "router",
8
+ "spa"
9
+ ],
10
+ "homepage": "https://github.com/colinlienard/sv-router",
11
+ "bugs": {
12
+ "url": "https://github.com/colinlienard/sv-router/issues"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/colinlienard/sv-router.git"
17
+ },
18
+ "license": "MIT",
19
+ "type": "module",
20
+ "exports": {
21
+ ".": {
22
+ "import": "./src/index.js",
23
+ "svelte": "./src/index.js",
24
+ "types": "./src/index.d.ts"
25
+ },
26
+ "./vite-plugin": {
27
+ "import": "./src/vite-plugin/index.js",
28
+ "types": "./src/vite-plugin/index.d.ts"
29
+ }
30
+ },
31
+ "bin": "./src/cli/index.js",
32
+ "files": [
33
+ "src"
34
+ ],
35
+ "dependencies": {
36
+ "esm-env": "^1.2.1"
37
+ },
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",
43
+ "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.13.0",
47
+ "prettier": "^3.4.2",
48
+ "prettier-plugin-jsdoc": "^1.3.0",
49
+ "prettier-plugin-svelte": "^3.3.2",
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"
55
+ },
56
+ "peerDependencies": {
57
+ "svelte": "^5"
58
+ },
59
+ "scripts": {
60
+ "ex:basic": "pnpm --filter basic-example",
61
+ "ex:file-based": "pnpm --filter file-based-example",
62
+ "test": "vitest",
63
+ "check": "tsc --noEmit && pnpm -r check",
64
+ "lint": "eslint .",
65
+ "lint:fix": "eslint . --fix",
66
+ "format": "prettier . --check",
67
+ "format:fix": "prettier . --write",
68
+ "changeset": "changeset"
69
+ }
70
+ }
@@ -1,9 +1,9 @@
1
- <script lang="ts">
2
- import type { Component } from 'svelte';
1
+ <script>
2
+ import { paramsStore } from './create-router.svelte.js';
3
3
  import RecursiveComponentTree from './RecursiveComponentTree.svelte';
4
- import { paramsStore } from './router.svelte.ts';
5
4
 
6
- let { tree }: { tree: Component[] } = $props();
5
+ /** @type {{ tree: import('svelte').Component[] }} */
6
+ let { tree } = $props();
7
7
 
8
8
  const FirstComponent = $derived(tree[0]);
9
9
  const restTree = $derived(tree.slice(1));
@@ -1,7 +1,7 @@
1
- <script lang="ts">
1
+ <script>
2
2
  import { on } from 'svelte/events';
3
+ import { componentTree, onGlobalClick, onNavigate } from './create-router.svelte.js';
3
4
  import RecursiveComponentTree from './RecursiveComponentTree.svelte';
4
- import { componentTree, onGlobalClick, onNavigate } from './router.svelte.ts';
5
5
 
6
6
  onNavigate();
7
7
 
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { writeRouterCode } from '../gen/write-router-code.js';
4
+
5
+ writeRouterCode();
@@ -0,0 +1,67 @@
1
+ import { BROWSER, DEV } from 'esm-env';
2
+ import { matchRoute } from './helpers/match-route.js';
3
+ import { constructPath, resolveRouteComponents } from './helpers/utils.js';
4
+
5
+ /** @type {import('./index.d.ts').Routes} */
6
+ export let routes;
7
+
8
+ /** @type {import('svelte').Component[]} */
9
+ export const componentTree = $state([]);
10
+
11
+ /** @type {Record<string, string>} */
12
+ export const paramsStore = $state({});
13
+
14
+ /**
15
+ * @template {import('./index.d.ts').Routes} T
16
+ * @param {T} r
17
+ * @returns {import('./index.d.ts').RouterMethods<T>}
18
+ */
19
+ export function createRouter(r) {
20
+ routes = r;
21
+
22
+ if (DEV && BROWSER) {
23
+ import('./helpers/validate-routes.js').then(({ validateRoutes }) => {
24
+ validateRoutes(routes);
25
+ });
26
+ }
27
+
28
+ 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;
38
+ },
39
+ };
40
+ }
41
+
42
+ export function onNavigate() {
43
+ if (!routes) {
44
+ throw new Error('Router not initialized: `createRouter` was not called.');
45
+ }
46
+ const { match, layouts, params } = matchRoute(globalThis.location.pathname, routes);
47
+ resolveRouteComponents(match ? [...layouts, match] : layouts).then((components) => {
48
+ Object.assign(componentTree, components);
49
+ });
50
+ Object.assign(paramsStore, params);
51
+ }
52
+
53
+ /** @param {Event} event */
54
+ export function onGlobalClick(event) {
55
+ const anchor = /** @type {HTMLElement} */ (event.target).closest('a');
56
+ if (!anchor) return;
57
+
58
+ if (anchor.hasAttribute('target') || anchor.hasAttribute('download')) return;
59
+
60
+ const url = new URL(anchor.href);
61
+ const currentOrigin = globalThis.location.origin;
62
+ if (url.origin !== currentOrigin) return;
63
+
64
+ event.preventDefault();
65
+ globalThis.history.pushState({}, '', anchor.href);
66
+ onNavigate();
67
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @type {{
3
+ * routesPath: string;
4
+ * readonly genCodeDirPath: string;
5
+ * readonly routerPath: string;
6
+ * readonly tsconfigPath: string;
7
+ * readonly genCodeAlias: string;
8
+ * }}
9
+ */
10
+ export const genConfig = {
11
+ routesPath: 'src/routes',
12
+ genCodeDirPath: '.router',
13
+ routerPath: '.router/router.ts',
14
+ tsconfigPath: '.router/tsconfig.json',
15
+ genCodeAlias: 'sv-router/generated',
16
+ };
@@ -0,0 +1,102 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /** @typedef {(string | { name: string; tree: FileTree })[]} FileTree */
5
+
6
+ /**
7
+ * @typedef {{
8
+ * [key: string]: string | GeneratedRoutes;
9
+ * }} GeneratedRoutes
10
+ */
11
+
12
+ const PARAM_FILENAME_REGEX = /\[(.*)\].svelte/g;
13
+
14
+ /**
15
+ * @param {string} routesPath
16
+ * @returns {string}
17
+ */
18
+ export function generateRouterCode(routesPath) {
19
+ const fileTree = buildFileTree(path.join(process.cwd(), routesPath));
20
+ const routeMap = createRouteMap(fileTree);
21
+ return createRouterCode(routeMap, path.join('..', routesPath));
22
+ }
23
+
24
+ /**
25
+ * @param {string} routesPath
26
+ * @returns {FileTree}
27
+ */
28
+ export function buildFileTree(routesPath) {
29
+ const entries = fs.readdirSync(routesPath);
30
+ /** @type {FileTree} */
31
+ const result = [];
32
+ for (const entry of entries) {
33
+ const stat = fs.lstatSync(path.join(routesPath, entry));
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);
38
+ }
39
+ }
40
+ return result;
41
+ }
42
+
43
+ /**
44
+ * @param {FileTree} fileTree
45
+ * @param {string} prefix
46
+ * @returns {GeneratedRoutes}
47
+ */
48
+ export function createRouteMap(fileTree, prefix = '') {
49
+ /** @type {GeneratedRoutes} */
50
+ const result = {};
51
+ for (const entry of fileTree) {
52
+ if (typeof entry === 'string') {
53
+ switch (entry) {
54
+ case 'index.svelte': {
55
+ result['/'] = prefix + entry;
56
+ break;
57
+ }
58
+ case '*.svelte': {
59
+ result['*'] = prefix + entry;
60
+ break;
61
+ }
62
+ case '_layout.svelte': {
63
+ result['layout'] = prefix + entry;
64
+ break;
65
+ }
66
+ default: {
67
+ if (PARAM_FILENAME_REGEX.test(entry)) {
68
+ result['/' + entry.replaceAll(PARAM_FILENAME_REGEX, ':$1')] = prefix + entry;
69
+ break;
70
+ }
71
+ result['/' + entry.replace('.svelte', '')] = prefix + entry;
72
+ break;
73
+ }
74
+ }
75
+ } else {
76
+ result['/' + entry.name] = createRouteMap(entry.tree, prefix + entry.name + '/');
77
+ }
78
+ }
79
+ return result;
80
+ }
81
+
82
+ /**
83
+ * @param {GeneratedRoutes} routes
84
+ * @param {string} routesPath
85
+ * @returns {string}
86
+ */
87
+ export function createRouterCode(routes, routesPath) {
88
+ if (!routesPath.endsWith('/')) {
89
+ routesPath += '/';
90
+ }
91
+
92
+ const jsonRoutes = JSON.stringify(routes, undefined, 2);
93
+ const withImports = jsonRoutes.replaceAll(
94
+ /"(.*)": "(.*)",?/g,
95
+ `"$1": () => import("${routesPath}$2"),`,
96
+ );
97
+ return [
98
+ 'import { createRouter } from "sv-router";',
99
+ '\n\n',
100
+ `export const { path, goto, params } = createRouter(${withImports});`,
101
+ ].join('');
102
+ }
@@ -0,0 +1,52 @@
1
+ /* eslint-disable no-console */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { genConfig } from './config.js';
5
+ import { generateRouterCode } from './generate-router-code.js';
6
+
7
+ export function writeRouterCode() {
8
+ try {
9
+ if (!fs.existsSync(genConfig.genCodeDirPath)) {
10
+ fs.mkdirSync(genConfig.genCodeDirPath);
11
+ }
12
+
13
+ // Write `.router/router.ts` file
14
+ const routerCode = generateRouterCode(genConfig.routesPath);
15
+ writeFileIfDifferent(genConfig.routerPath, routerCode);
16
+
17
+ // Write `.router/tsconfig.json` file
18
+ const tsConfig = {
19
+ compilerOptions: {
20
+ module: 'preserve',
21
+ moduleResolution: 'bundler',
22
+ paths: {
23
+ [genConfig.genCodeAlias]: [path.join('..', genConfig.routerPath)],
24
+ },
25
+ },
26
+ include: [
27
+ '../src/**/*.js',
28
+ '../src/**/*.ts',
29
+ '../src/**/*.svelte',
30
+ '../tests/**/*.js',
31
+ '../tests/**/*.ts',
32
+ '../tests/**/*.svelte',
33
+ './router.ts',
34
+ ],
35
+ };
36
+ writeFileIfDifferent(genConfig.tsconfigPath, JSON.stringify(tsConfig, undefined, 2));
37
+
38
+ console.log('✅️ Routes generated');
39
+ } catch (error) {
40
+ console.error('Error during routes generation:', error);
41
+ }
42
+ }
43
+
44
+ /**
45
+ * @param {string} filePath
46
+ * @param {string} content
47
+ */
48
+ function writeFileIfDifferent(filePath, content) {
49
+ if (!fs.existsSync(filePath) || fs.readFileSync(filePath, 'utf8') !== content) {
50
+ fs.writeFileSync(filePath, content);
51
+ }
52
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * @typedef {import('../index.d.ts').LayoutComponent} LayoutComponent
3
+ *
4
+ * @typedef {import('../index.d.ts').RouteComponent} RouteComponent
5
+ *
6
+ * @typedef {import('../index.d.ts').Routes} Routes
7
+ */
8
+
9
+ /**
10
+ * @param {string} pathname
11
+ * @param {Routes} routes
12
+ * @returns {{
13
+ * match: RouteComponent | undefined;
14
+ * layouts: LayoutComponent[];
15
+ * params: Record<string, string>;
16
+ * breakFromLayouts: boolean;
17
+ * }}
18
+ */
19
+ export function matchRoute(pathname, routes) {
20
+ // Remove trailing slash
21
+ if (pathname.length > 1 && pathname.endsWith('/')) {
22
+ pathname = pathname.slice(0, -1);
23
+ }
24
+ const pathParts = pathname.split('/');
25
+ const allRouteParts = sortRoutes(Object.keys(routes)).map((route) => route.split('/'));
26
+
27
+ /** @type {RouteComponent | undefined} */
28
+ let match;
29
+
30
+ /** @type {LayoutComponent[]} */
31
+ let layouts = [];
32
+
33
+ /** @type {Record<string, string>} */
34
+ let params = {};
35
+
36
+ let breakFromLayouts = false;
37
+
38
+ outer: for (const routeParts of allRouteParts) {
39
+ for (let [index, routePart] of sortRoutes(routeParts).entries()) {
40
+ breakFromLayouts = routePart.startsWith('(') && routePart.endsWith(')');
41
+ if (breakFromLayouts) {
42
+ routePart = routePart.slice(1, -1);
43
+ }
44
+
45
+ const pathPart = pathParts[index];
46
+ if (routePart.startsWith(':')) {
47
+ params[routePart.slice(1)] = pathPart;
48
+ } else if (routePart === '*') {
49
+ match = /** @type {RouteComponent} */ (
50
+ routes[/** @type {keyof Routes} */ (routeParts.join('/'))]
51
+ );
52
+ break outer;
53
+ } else if (routePart !== pathPart) {
54
+ break;
55
+ }
56
+
57
+ if (index !== routeParts.length - 1) {
58
+ continue;
59
+ }
60
+
61
+ if (!breakFromLayouts && 'layout' in routes && routes.layout) {
62
+ layouts.push(routes.layout);
63
+ }
64
+
65
+ const routeMatch = /** @type {RouteComponent} */ (
66
+ routes[/** @type {keyof Routes} */ (routeParts.join('/'))]
67
+ );
68
+
69
+ if (typeof routeMatch === 'function') {
70
+ if (routeParts.length === pathParts.length) {
71
+ match = routeMatch;
72
+ } else {
73
+ continue;
74
+ }
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
+ }
86
+ }
87
+ }
88
+ break outer;
89
+ }
90
+ }
91
+
92
+ return { match, layouts, params, breakFromLayouts };
93
+ }
94
+
95
+ /**
96
+ * @param {string[]} routes
97
+ * @returns {string[]}
98
+ */
99
+ export function sortRoutes(routes) {
100
+ return routes.toSorted((a, b) => getRoutePriority(a) - getRoutePriority(b));
101
+ }
102
+
103
+ /**
104
+ * @param {string} route
105
+ * @returns {number}
106
+ */
107
+ function getRoutePriority(route) {
108
+ if (route === '' || route === '/') return 1;
109
+ if (route === '*') return 4;
110
+ if (route.includes(':')) return 3;
111
+ return 2;
112
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @param {string} path
3
+ * @param {Record<string, string>} [params]
4
+ * @returns {string}
5
+ */
6
+ export function constructPath(path, params) {
7
+ if (!params) return path;
8
+
9
+ let result = path;
10
+ for (const key in params) {
11
+ result = result.replace(`:${key}`, params[key]);
12
+ }
13
+ return result;
14
+ }
15
+
16
+ /**
17
+ * @param {import('../index.d.ts').RouteComponent[]} input
18
+ * @returns {Promise<import('svelte').Component[]>}
19
+ */
20
+ export function resolveRouteComponents(input) {
21
+ return Promise.all(input.map((c) => resolveRouteComponent(c)));
22
+ }
23
+
24
+ /**
25
+ * @param {import('../index.d.ts').RouteComponent} input
26
+ * @returns {Promise<import('svelte').Component>}
27
+ */
28
+ export function resolveRouteComponent(input) {
29
+ return new Promise((resolve) => {
30
+ if (isLazyImport(input)) {
31
+ Promise.resolve(input()).then((module) => {
32
+ resolve(module.default);
33
+ });
34
+ } else {
35
+ resolve(input);
36
+ }
37
+ });
38
+ }
39
+
40
+ /**
41
+ * @param {unknown} input
42
+ * @returns {input is import('../index.d.ts').LazyRouteComponent}
43
+ */
44
+ export function isLazyImport(input) {
45
+ return typeof input === 'function' && !!/\(\)\s?=>\s?import\(.*\)/g.test(String(input));
46
+ }
@@ -0,0 +1,46 @@
1
+ /** @param {import('../index.d.ts').Routes} routes */
2
+ export function validateRoutes(routes) {
3
+ const paths = getRoutePaths(routes);
4
+ const wildcardPaths = paths.filter((path) => path.endsWith('*'));
5
+ for (const wildcardPath of wildcardPaths) {
6
+ const parentPath = wildcardPath.slice(0, -1);
7
+ const dynamicPath = paths.find(
8
+ (p) =>
9
+ p !== '/' &&
10
+ !p.endsWith('*') &&
11
+ p.startsWith(parentPath === '' ? '/:' : parentPath) &&
12
+ p.match(/:[^/]*$/g), // Match dynamic paths without slashes after the colon
13
+ );
14
+ if (dynamicPath) {
15
+ console.warn(
16
+ `Router warning: Wildcard route \`${wildcardPath}\` should not be at the same level as dynamic route \`${dynamicPath}\`.`,
17
+ );
18
+ }
19
+ }
20
+ }
21
+
22
+ /**
23
+ * @param {import('../index.d.ts').Routes} routes
24
+ * @returns {string[]}
25
+ */
26
+ export function getRoutePaths(routes) {
27
+ const paths = [];
28
+ for (const [key, value] of Object.entries(routes)) {
29
+ if (typeof value === 'object') {
30
+ paths.push(
31
+ ...getRoutePaths(value).map((path) => {
32
+ if (path === '*') {
33
+ return key + '/*';
34
+ }
35
+ if (path === '/') {
36
+ return key;
37
+ }
38
+ return key + path;
39
+ }),
40
+ );
41
+ } else {
42
+ paths.push(key);
43
+ }
44
+ }
45
+ return paths;
46
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,77 @@
1
+ import type { Component, Snippet } from 'svelte';
2
+
3
+ /**
4
+ * Setup a new router instance with the given routes.
5
+ *
6
+ * ```js
7
+ * export const { path, goto, params } = createRouter({
8
+ * '/': Home,
9
+ * '/about': About,
10
+ * ...
11
+ * });
12
+ * ```
13
+ */
14
+ export function createRouter<T extends Routes>(r: T): RouterMethods<T>;
15
+ export const Router: Component;
16
+
17
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
18
+ type BaseProps = {};
19
+
20
+ export type LazyRouteComponent<Props extends BaseProps = BaseProps> = () => Promise<{
21
+ default: Component<Props>;
22
+ }>;
23
+
24
+ export type RouteComponent<Props extends BaseProps = any> =
25
+ | Component<Props>
26
+ | LazyRouteComponent<Props>;
27
+ export type LayoutComponent = RouteComponent<{ children: Snippet }>;
28
+
29
+ export type Routes = {
30
+ [_: `/${string}`]: RouteComponent | Routes;
31
+ '*'?: RouteComponent;
32
+ layout?: LayoutComponent;
33
+ };
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>;
39
+ };
40
+
41
+ export type Path<T extends Routes> = RemoveParenthesis<
42
+ RemoveLastSlash<RecursiveKeys<StripNonRoutes<T>>>
43
+ >;
44
+
45
+ export type ConstructPathArgs<T extends string> =
46
+ PathParams<T> extends never ? [T] : [T, PathParams<T>];
47
+
48
+ export type PathParams<T extends string> =
49
+ ExtractParams<T> extends never ? never : Record<ExtractParams<T>, string>;
50
+
51
+ export type AllParams<T extends Routes> = Partial<Record<ExtractParams<RecursiveKeys<T>>, string>>;
52
+
53
+ 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];
57
+ };
58
+
59
+ type RecursiveKeys<T extends Routes, Prefix extends string = ''> = {
60
+ [K in keyof T]: K extends string
61
+ ? T[K] extends Routes
62
+ ? RecursiveKeys<T[K], `${Prefix}${K}`>
63
+ : `${Prefix}${K}`
64
+ : never;
65
+ }[keyof T];
66
+
67
+ type RemoveLastSlash<T extends string> = T extends '/' ? T : T extends `${infer R}/` ? R : T;
68
+
69
+ type RemoveParenthesis<T extends string> = T extends `${infer A}(${infer B})${infer C}`
70
+ ? RemoveParenthesis<`${A}${B}${C}`>
71
+ : T;
72
+
73
+ type ExtractParams<T extends string> = T extends `${string}:${infer Param}/${infer Rest}`
74
+ ? Param | ExtractParams<`/${Rest}`>
75
+ : T extends `${string}:${infer Param}`
76
+ ? Param
77
+ : never;
package/src/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { createRouter } from './create-router.svelte.js';
2
+ export { default as Router } from './Router.svelte';
@@ -0,0 +1,12 @@
1
+ import type { Plugin } from 'vite';
2
+
3
+ export type RouterOptions = {
4
+ /**
5
+ * The path to the routes folder.
6
+ *
7
+ * @default 'src/routes'
8
+ */
9
+ path?: string;
10
+ };
11
+
12
+ export const router: (options?: RouterOptions) => Plugin;
@@ -0,0 +1 @@
1
+ export { router } from './plugin.js';
@@ -0,0 +1,42 @@
1
+ import path from 'node:path';
2
+ import { genConfig } from '../gen/config.js';
3
+ import { writeRouterCode } from '../gen/write-router-code.js';
4
+
5
+ /**
6
+ * @param {import('./index.d.ts').RouterOptions | undefined} options
7
+ * @returns {import('vite').Plugin}
8
+ */
9
+ export function router(options) {
10
+ if (options?.path) {
11
+ genConfig.routesPath = options.path;
12
+ }
13
+
14
+ return {
15
+ name: 'sv-router',
16
+ config(config) {
17
+ if (!config.resolve) {
18
+ config.resolve = {};
19
+ }
20
+ if (!config.resolve.alias) {
21
+ config.resolve.alias = {};
22
+ }
23
+
24
+ const replacement = path.resolve(process.cwd(), genConfig.routerPath);
25
+
26
+ if (Array.isArray(config.resolve.alias)) {
27
+ config.resolve.alias.push({ find: genConfig.genCodeAlias, replacement });
28
+ } else {
29
+ /** @type {Record<string, string>} */ (config.resolve.alias)[genConfig.genCodeAlias] =
30
+ replacement;
31
+ }
32
+ },
33
+ buildStart() {
34
+ writeRouterCode();
35
+ },
36
+ watchChange(file) {
37
+ if (file.includes(genConfig.routesPath)) {
38
+ writeRouterCode();
39
+ }
40
+ },
41
+ };
42
+ }
@@ -1,8 +0,0 @@
1
- import type { Component } from 'svelte';
2
- import RecursiveComponentTree from './RecursiveComponentTree.svelte';
3
- type $$ComponentProps = {
4
- tree: Component[];
5
- };
6
- declare const RecursiveComponentTree: Component<$$ComponentProps, {}, "">;
7
- type RecursiveComponentTree = ReturnType<typeof RecursiveComponentTree>;
8
- export default RecursiveComponentTree;
@@ -1,3 +0,0 @@
1
- import type { PathParams } from '../types/types.ts';
2
- export type ConstructPathArgs<T extends string> = PathParams<T> extends never ? [T] : [T, PathParams<T>];
3
- export declare function constructPath<T extends string>(...args: ConstructPathArgs<T>): string;
@@ -1,10 +0,0 @@
1
- export function constructPath(...args) {
2
- const [path, params] = args;
3
- if (!params)
4
- return path;
5
- let result = path;
6
- for (const key in params) {
7
- result = result.replace(`:${key}`, params[key]);
8
- }
9
- return result;
10
- }
@@ -1,7 +0,0 @@
1
- import type { LayoutComponent, RouteComponent, Routes } from '../types/types.ts';
2
- export declare function matchRoute(pathname: string, routes: Routes): {
3
- match: RouteComponent | undefined;
4
- layouts: LayoutComponent[];
5
- params: Record<string, string>;
6
- };
7
- export declare function sortRoutes(routes: string[]): string[];
@@ -1,65 +0,0 @@
1
- export function matchRoute(pathname, routes) {
2
- // Remove trailing slash
3
- if (pathname.length > 1 && pathname.endsWith('/')) {
4
- pathname = pathname.slice(0, -1);
5
- }
6
- const pathParts = pathname.split('/');
7
- const allRouteParts = sortRoutes(Object.keys(routes)).map((route) => route.split('/'));
8
- let match;
9
- const layouts = [];
10
- let params = {};
11
- outer: for (const routeParts of allRouteParts) {
12
- for (const [index, routePart] of sortRoutes(routeParts).entries()) {
13
- const pathPart = pathParts[index];
14
- if (routePart.startsWith(':')) {
15
- params[routePart.slice(1)] = pathPart;
16
- }
17
- else if (routePart === '*') {
18
- match = routes[routeParts.join('/')];
19
- break outer;
20
- }
21
- else if (routePart !== pathPart) {
22
- break;
23
- }
24
- const routeMatch = routes[routeParts.join('/')];
25
- if (typeof routeMatch !== 'function' &&
26
- routeMatch?.layout &&
27
- !layouts.includes(routeMatch.layout)) {
28
- layouts.push(routeMatch.layout);
29
- }
30
- if (index === routeParts.length - 1) {
31
- if (typeof routeMatch === 'function') {
32
- if (routeParts.length === pathParts.length) {
33
- match = routeMatch;
34
- }
35
- else {
36
- continue;
37
- }
38
- }
39
- else if (routeMatch) {
40
- const nestedPathname = '/' + pathParts.slice(index + 1).join('/');
41
- const result = matchRoute(nestedPathname, routeMatch);
42
- if (result) {
43
- match = result.match;
44
- params = { ...params, ...result.params };
45
- layouts.push(...result.layouts);
46
- }
47
- }
48
- break outer;
49
- }
50
- }
51
- }
52
- return { match, layouts, params };
53
- }
54
- export function sortRoutes(routes) {
55
- return routes.toSorted((a, b) => getRoutePriority(a) - getRoutePriority(b));
56
- }
57
- function getRoutePriority(route) {
58
- if (route === '' || route === '/')
59
- return 1;
60
- if (route === '*')
61
- return 4;
62
- if (route.includes(':'))
63
- return 3;
64
- return 2;
65
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,133 +0,0 @@
1
- import { matchRoute, sortRoutes } from "./match-route.js";
2
- const Home = (() => 'Home');
3
- const Posts = (() => 'Posts');
4
- const StaticPost = (() => 'StaticPost');
5
- const DynamicPost = (() => 'DynamicPost');
6
- const DynamicPostComment = (() => 'DynamicPostComment');
7
- const UserNotFound = (() => 'UserNotFound');
8
- const PageNotFound = (() => 'PageNotFound');
9
- const Layout1 = (() => 'Layout1');
10
- const Layout2 = (() => 'Layout2');
11
- describe('matchRoute', () => {
12
- describe.each([
13
- {
14
- mode: 'flat',
15
- routes: {
16
- '/': Home,
17
- '/posts': Posts,
18
- '/posts/static': StaticPost,
19
- '/posts/:id': DynamicPost,
20
- '/posts/:id/:commentId': DynamicPostComment,
21
- '/users/*': UserNotFound,
22
- '*': PageNotFound,
23
- },
24
- },
25
- {
26
- mode: 'flat unordered',
27
- routes: {
28
- '/posts/:id/:commentId': DynamicPostComment,
29
- '/posts': Posts,
30
- '/users/*': UserNotFound,
31
- '/posts/static': StaticPost,
32
- '*': PageNotFound,
33
- '/posts/:id': DynamicPost,
34
- '/': Home,
35
- },
36
- },
37
- {
38
- mode: 'tree',
39
- routes: {
40
- '/': Home,
41
- '/posts': {
42
- '/': Posts,
43
- '/static': StaticPost,
44
- '/:id': {
45
- '/': DynamicPost,
46
- '/:commentId': DynamicPostComment,
47
- layout: Layout2,
48
- },
49
- layout: Layout1,
50
- },
51
- '/users/*': UserNotFound,
52
- '*': PageNotFound,
53
- },
54
- },
55
- {
56
- mode: 'tree unordered',
57
- routes: {
58
- '*': PageNotFound,
59
- '/posts': {
60
- '/:id': {
61
- '/:commentId': DynamicPostComment,
62
- '/': DynamicPost,
63
- layout: Layout2,
64
- },
65
- '/': Posts,
66
- '/static': StaticPost,
67
- layout: Layout1,
68
- },
69
- '/users/*': UserNotFound,
70
- '/': Home,
71
- },
72
- },
73
- ])('$mode paths', ({ mode, routes: r }) => {
74
- const routes = r;
75
- it('should match the root route', () => {
76
- const { match } = matchRoute('/', routes);
77
- expect(match).toEqual(Home);
78
- });
79
- it('should match a simple route', () => {
80
- const { match } = matchRoute('/posts', routes);
81
- expect(match).toEqual(Posts);
82
- });
83
- it('should match a simple route with a trailing slash', () => {
84
- const { match } = matchRoute('/posts', routes);
85
- expect(match).toEqual(Posts);
86
- });
87
- it('should match a nested route', () => {
88
- const { match } = matchRoute('/posts/static', routes);
89
- expect(match).toEqual(StaticPost);
90
- });
91
- it('should match a dynamic route and return a param', () => {
92
- const { match, params } = matchRoute('/posts/bar', routes);
93
- expect(match).toEqual(DynamicPost);
94
- expect(params).toEqual({ id: 'bar' });
95
- });
96
- it('should match multiple dynamic nested routes and return params', () => {
97
- const { match, params } = matchRoute('/posts/bar/baz', routes);
98
- expect(match).toEqual(DynamicPostComment);
99
- expect(params).toEqual({ id: 'bar', commentId: 'baz' });
100
- });
101
- if (mode === 'nested') {
102
- it('should match routes with layout', () => {
103
- const { layouts: layouts1 } = matchRoute('/', routes);
104
- const { layouts: layouts2 } = matchRoute('/posts', routes);
105
- const { layouts: layouts3 } = matchRoute('/posts/static', routes);
106
- const { layouts: layouts4 } = matchRoute('/posts/bar/baz', routes);
107
- expect(layouts1).toEqual([]);
108
- expect(layouts2).toEqual([Layout1]);
109
- expect(layouts3).toEqual([Layout1]);
110
- expect(layouts4).toEqual([Layout1, Layout2]);
111
- });
112
- }
113
- it('should match wildcard nested route', () => {
114
- const { match } = matchRoute('/users/notfound', routes);
115
- expect(match).toEqual(UserNotFound);
116
- });
117
- it('should match wildcard route', () => {
118
- const { match } = matchRoute('/notfound', routes);
119
- expect(match).toEqual(PageNotFound);
120
- });
121
- it('should not match any route', () => {
122
- delete routes['*'];
123
- const { match } = matchRoute('/notfound', routes);
124
- expect(match).toBeUndefined();
125
- });
126
- });
127
- });
128
- describe('sortRoutes', () => {
129
- it('should sort routes', () => {
130
- const result = sortRoutes(['/:id', '*', '/foo', '', '/']);
131
- expect(result).toEqual(['', '/', '/foo', '/:id', '*']);
132
- });
133
- });
@@ -1,5 +0,0 @@
1
- import type { Component } from 'svelte';
2
- import type { LazyRouteComponent, RouteComponent } from '../types/types.ts';
3
- export declare function resolveRouteComponents(input: RouteComponent<any>[]): Promise<Component[]>;
4
- export declare function resolveRouteComponent(input: RouteComponent): Promise<Component>;
5
- export declare function isLazyImport(input: unknown): input is LazyRouteComponent;
@@ -1,18 +0,0 @@
1
- export function resolveRouteComponents(input) {
2
- return Promise.all(input.map((c) => resolveRouteComponent(c)));
3
- }
4
- export function resolveRouteComponent(input) {
5
- return new Promise((resolve) => {
6
- if (isLazyImport(input)) {
7
- Promise.resolve(input()).then((module) => {
8
- resolve(module.default);
9
- });
10
- }
11
- else {
12
- resolve(input);
13
- }
14
- });
15
- }
16
- export function isLazyImport(input) {
17
- return typeof input === 'function' && !!/\(\)\s?=>\s?import\(.*\)/g.test(String(input));
18
- }
@@ -1,3 +0,0 @@
1
- import type { Routes } from '../types/types.ts';
2
- export declare function validateRoutes(routes: Routes): void;
3
- export declare function getRoutePaths(routes: Routes): string[];
@@ -1,34 +0,0 @@
1
- export function validateRoutes(routes) {
2
- const paths = getRoutePaths(routes);
3
- const wildcardPaths = paths.filter((path) => path.endsWith('*'));
4
- for (const wildcardPath of wildcardPaths) {
5
- const parentPath = wildcardPath.slice(0, -1);
6
- const dynamicPath = paths.find((p) => p !== '/' &&
7
- !p.endsWith('*') &&
8
- p.startsWith(parentPath === '' ? '/:' : parentPath) &&
9
- p.match(/:[^/]*$/g));
10
- if (dynamicPath) {
11
- console.warn(`Router warning: Wildcard route \`${wildcardPath}\` should not be at the same level as dynamic route \`${dynamicPath}\`.`);
12
- }
13
- }
14
- }
15
- export function getRoutePaths(routes) {
16
- const paths = [];
17
- for (const [key, value] of Object.entries(routes)) {
18
- if (typeof value === 'object') {
19
- paths.push(...getRoutePaths(value).map((path) => {
20
- if (path === '*') {
21
- return key + '/*';
22
- }
23
- if (path === '/') {
24
- return key;
25
- }
26
- return key + path;
27
- }));
28
- }
29
- else {
30
- paths.push(key);
31
- }
32
- }
33
- return paths;
34
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,67 +0,0 @@
1
- import { getRoutePaths, validateRoutes } from "./validate-routes.js";
2
- const component = (() => ({}));
3
- const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(vi.fn());
4
- beforeEach(() => {
5
- vi.clearAllMocks();
6
- });
7
- describe('validateRoutes', () => {
8
- it('should validate routes', () => {
9
- validateRoutes({
10
- '/': component,
11
- '/about': component,
12
- '/posts': {
13
- '/': component,
14
- '/:id': component,
15
- },
16
- '*': component,
17
- });
18
- expect(consoleSpy).not.toHaveBeenCalled();
19
- });
20
- it('should raise a warning if a wildcard route is at the same level as a dynamic route', () => {
21
- validateRoutes({
22
- '/': component,
23
- '/:id': component,
24
- '*': component,
25
- });
26
- expect(consoleSpy).toHaveBeenCalledWith('Router warning: Wildcard route `*` should not be at the same level as dynamic route `/:id`.');
27
- });
28
- it.each([
29
- {
30
- '/': component,
31
- '/posts/*': component,
32
- '/posts/:id': component,
33
- '/:id': component,
34
- '*': component,
35
- },
36
- {
37
- '/': component,
38
- '/posts': {
39
- '*': component,
40
- '/:id': component,
41
- },
42
- '/:id': component,
43
- '*': component,
44
- },
45
- ])('should raise multiple warnings if wildcard routes are at the same level as dynamic routes', (routes) => {
46
- validateRoutes(routes);
47
- expect(consoleSpy).toHaveBeenCalledTimes(2);
48
- expect(consoleSpy).toHaveBeenCalledWith('Router warning: Wildcard route `*` should not be at the same level as dynamic route `/:id`.');
49
- expect(consoleSpy).toHaveBeenCalledWith('Router warning: Wildcard route `/posts/*` should not be at the same level as dynamic route `/posts/:id`.');
50
- });
51
- });
52
- describe('getRoutePaths', () => {
53
- it('should return all paths in an array', () => {
54
- const result = getRoutePaths({
55
- '/': component,
56
- '/about': component,
57
- '/posts': {
58
- '/': component,
59
- '/:id': component,
60
- '*': component,
61
- },
62
- '/foo/bar': component,
63
- '*': component,
64
- });
65
- expect(result).toEqual(['/', '/about', '/posts', '/posts/:id', '/posts/*', '/foo/bar', '*']);
66
- });
67
- });
package/dist/index.d.ts DELETED
@@ -1,2 +0,0 @@
1
- export { default as Router } from './Router.svelte';
2
- export { createRouter } from './router.svelte.ts';
package/dist/index.js DELETED
@@ -1,2 +0,0 @@
1
- export { default as Router } from './Router.svelte';
2
- export { createRouter } from "./router.svelte.js";
@@ -1,13 +0,0 @@
1
- import type { Component } from 'svelte';
2
- import { type ConstructPathArgs } from './helpers/construct-path.ts';
3
- import type { AllParams, Path, Routes } from './types/types.ts';
4
- export declare let routes: Routes;
5
- export declare const componentTree: Component<{}, {}, string>[];
6
- export declare const paramsStore: Record<string, string>;
7
- export declare function createRouter<T extends Routes>(r: T): {
8
- path<U extends Path<T>>(...args: ConstructPathArgs<U>): string;
9
- goto<U extends Path<T>>(...args: ConstructPathArgs<U>): void;
10
- params(): AllParams<T>;
11
- };
12
- export declare function onNavigate(): void;
13
- export declare function onGlobalClick(event: Event): void;
@@ -1,50 +0,0 @@
1
- import { BROWSER, DEV } from 'esm-env';
2
- import { constructPath } from "./helpers/construct-path.js";
3
- import { matchRoute } from "./helpers/match-route.js";
4
- import { resolveRouteComponents } from "./helpers/utils.js";
5
- export let routes;
6
- export const componentTree = $state([]);
7
- export const paramsStore = $state({});
8
- export function createRouter(r) {
9
- routes = r;
10
- if (DEV && BROWSER) {
11
- import('./helpers/validate-routes.ts').then(({ validateRoutes }) => {
12
- validateRoutes(routes);
13
- });
14
- }
15
- return {
16
- path(...args) {
17
- return constructPath(...args);
18
- },
19
- goto(...args) {
20
- const path = constructPath(...args);
21
- globalThis.history.pushState({}, '', path);
22
- onNavigate();
23
- },
24
- params() {
25
- const readonly = $derived(paramsStore);
26
- return readonly;
27
- },
28
- };
29
- }
30
- export function onNavigate() {
31
- const { match, layouts, params } = matchRoute(globalThis.location.pathname, routes);
32
- resolveRouteComponents(match ? [...layouts, match] : layouts).then((components) => {
33
- Object.assign(componentTree, components);
34
- });
35
- Object.assign(paramsStore, params);
36
- }
37
- export function onGlobalClick(event) {
38
- const anchor = event.target.closest('a');
39
- if (!anchor)
40
- return;
41
- if (anchor.hasAttribute('target') || anchor.hasAttribute('download'))
42
- return;
43
- const url = new URL(anchor.href);
44
- const currentOrigin = globalThis.location.origin;
45
- if (url.origin !== currentOrigin)
46
- return;
47
- event.preventDefault();
48
- globalThis.history.pushState({}, '', anchor.href);
49
- onNavigate();
50
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1,26 +0,0 @@
1
- import type { Component, Snippet } from 'svelte';
2
- type BaseProps = Record<string, any>;
3
- export type LazyRouteComponent<Props extends BaseProps = BaseProps> = () => Promise<{
4
- default: Component<Props>;
5
- }>;
6
- export type RouteComponent<Props extends BaseProps = BaseProps> = Component<Props> | LazyRouteComponent<Props>;
7
- export type LayoutComponent = RouteComponent<{
8
- children: Snippet;
9
- }>;
10
- export type Routes = {
11
- [_: `/${string}`]: RouteComponent | Routes;
12
- '*'?: RouteComponent;
13
- layout?: LayoutComponent;
14
- };
15
- export type Path<T extends Routes> = RemoveLastSlash<RecursiveKeys<StripNonRoutes<T>>>;
16
- export type PathParams<T extends string> = ExtractParams<T> extends never ? never : Record<ExtractParams<T>, string>;
17
- export type AllParams<T extends Routes> = Partial<Record<ExtractParams<RecursiveKeys<T>>, string>>;
18
- type StripNonRoutes<T extends Routes> = {
19
- [K in keyof T as K extends '*' ? never : K extends 'layout' ? never : K]: T[K] extends Routes ? StripNonRoutes<T[K]> : T[K];
20
- };
21
- type RecursiveKeys<T extends Routes, Prefix extends string = ''> = {
22
- [K in keyof T]: K extends string ? T[K] extends Routes ? RecursiveKeys<T[K], `${Prefix}${K}`> : `${Prefix}${K}` : never;
23
- }[keyof T];
24
- type RemoveLastSlash<T extends string> = T extends '/' ? T : T extends `${infer R}/` ? R : T;
25
- type ExtractParams<T extends string> = T extends `${string}:${infer Param}/${infer Rest}` ? Param | ExtractParams<`/${Rest}`> : T extends `${string}:${infer Param}` ? Param : never;
26
- export {};
@@ -1 +0,0 @@
1
- export {};