zylaris 1.0.2
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/LICENSE +21 -0
- package/README.md +558 -0
- package/Zylaris.js.png +0 -0
- package/examples/default/index.html +13 -0
- package/examples/default/package.json +23 -0
- package/examples/default/src/app/about/page.tsx +18 -0
- package/examples/default/src/app/counter/page.tsx +22 -0
- package/examples/default/src/app/global.css +225 -0
- package/examples/default/src/app/layout.tsx +33 -0
- package/examples/default/src/app/page.tsx +14 -0
- package/examples/default/src/entry-client.tsx +87 -0
- package/examples/default/src/entry-server.tsx +52 -0
- package/examples/default/src/router.ts +60 -0
- package/examples/default/tsconfig.json +28 -0
- package/examples/default/zylaris.config.ts +24 -0
- package/package.json +34 -0
- package/packages/adapter/package.json +59 -0
- package/packages/adapter/src/adapters/bun.ts +215 -0
- package/packages/adapter/src/adapters/cloudflare.ts +278 -0
- package/packages/adapter/src/adapters/deno.ts +219 -0
- package/packages/adapter/src/adapters/netlify.ts +274 -0
- package/packages/adapter/src/adapters/node.ts +155 -0
- package/packages/adapter/src/adapters/static.ts +134 -0
- package/packages/adapter/src/adapters/vercel.ts +239 -0
- package/packages/adapter/src/index.ts +115 -0
- package/packages/adapter/src/lib/builder.ts +361 -0
- package/packages/adapter/src/types.ts +191 -0
- package/packages/adapter/tsconfig.json +8 -0
- package/packages/cli/package.json +43 -0
- package/packages/cli/src/bin.ts +107 -0
- package/packages/cli/src/commands/build.ts +197 -0
- package/packages/cli/src/commands/create.ts +222 -0
- package/packages/cli/src/commands/deploy.ts +90 -0
- package/packages/cli/src/commands/dev.ts +108 -0
- package/packages/cli/src/index.ts +6 -0
- package/packages/cli/tsconfig.json +9 -0
- package/packages/compiler/package.json +39 -0
- package/packages/compiler/src/index.ts +210 -0
- package/packages/compiler/src/jit.ts +187 -0
- package/packages/compiler/tsconfig.json +9 -0
- package/packages/core/package.json +55 -0
- package/packages/core/src/components.test.ts +125 -0
- package/packages/core/src/components.ts +181 -0
- package/packages/core/src/config.ts +204 -0
- package/packages/core/src/hooks.ts +142 -0
- package/packages/core/src/index.ts +59 -0
- package/packages/core/src/jsx-runtime.ts +46 -0
- package/packages/core/tsconfig.json +16 -0
- package/packages/dev-server/package.json +51 -0
- package/packages/dev-server/src/index.ts +306 -0
- package/packages/dev-server/src/jit-middleware.ts +78 -0
- package/packages/dev-server/tsconfig.json +9 -0
- package/packages/plugins/package.json +44 -0
- package/packages/plugins/src/cdn/loader.ts +275 -0
- package/packages/plugins/src/index.ts +238 -0
- package/packages/plugins/src/loaders/auto-import.ts +219 -0
- package/packages/plugins/src/loaders/external.ts +332 -0
- package/packages/plugins/src/transforms/index.ts +407 -0
- package/packages/plugins/src/types.ts +296 -0
- package/packages/plugins/tsconfig.json +8 -0
- package/packages/reactivity/package.json +36 -0
- package/packages/reactivity/src/computed.d.ts +3 -0
- package/packages/reactivity/src/computed.d.ts.map +1 -0
- package/packages/reactivity/src/computed.js +64 -0
- package/packages/reactivity/src/computed.js.map +1 -0
- package/packages/reactivity/src/computed.test.ts +83 -0
- package/packages/reactivity/src/computed.ts +69 -0
- package/packages/reactivity/src/index.d.ts +6 -0
- package/packages/reactivity/src/index.d.ts.map +1 -0
- package/packages/reactivity/src/index.js +7 -0
- package/packages/reactivity/src/index.js.map +1 -0
- package/packages/reactivity/src/index.ts +18 -0
- package/packages/reactivity/src/resource.d.ts +6 -0
- package/packages/reactivity/src/resource.d.ts.map +1 -0
- package/packages/reactivity/src/resource.js +43 -0
- package/packages/reactivity/src/resource.js.map +1 -0
- package/packages/reactivity/src/resource.test.ts +70 -0
- package/packages/reactivity/src/resource.ts +59 -0
- package/packages/reactivity/src/signal.d.ts +7 -0
- package/packages/reactivity/src/signal.d.ts.map +1 -0
- package/packages/reactivity/src/signal.js +145 -0
- package/packages/reactivity/src/signal.js.map +1 -0
- package/packages/reactivity/src/signal.test.ts +130 -0
- package/packages/reactivity/src/signal.ts +207 -0
- package/packages/reactivity/src/store.d.ts +4 -0
- package/packages/reactivity/src/store.d.ts.map +1 -0
- package/packages/reactivity/src/store.js +62 -0
- package/packages/reactivity/src/store.js.map +1 -0
- package/packages/reactivity/src/store.test.ts +38 -0
- package/packages/reactivity/src/store.ts +111 -0
- package/packages/reactivity/src/types.d.ts +43 -0
- package/packages/reactivity/src/types.d.ts.map +1 -0
- package/packages/reactivity/src/types.js +3 -0
- package/packages/reactivity/src/types.js.map +1 -0
- package/packages/reactivity/src/types.ts +43 -0
- package/packages/reactivity/tsconfig.json +9 -0
- package/packages/router/package.json +44 -0
- package/packages/router/src/components.tsx +150 -0
- package/packages/router/src/fs-router.ts +163 -0
- package/packages/router/src/index.ts +22 -0
- package/packages/router/src/router.test.ts +111 -0
- package/packages/router/src/router.ts +112 -0
- package/packages/router/src/types.ts +69 -0
- package/packages/router/tsconfig.json +10 -0
- package/packages/server/package.json +41 -0
- package/packages/server/src/action.test.ts +102 -0
- package/packages/server/src/action.ts +201 -0
- package/packages/server/src/api.ts +143 -0
- package/packages/server/src/index.ts +18 -0
- package/packages/server/src/types.ts +72 -0
- package/packages/server/tsconfig.json +9 -0
- package/pnpm-workspace.yaml +4 -0
- package/scripts/publish.ps1 +138 -0
- package/scripts/publish.sh +142 -0
- package/tsconfig.json +28 -0
- package/turbo.json +24 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zylaris/router",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "File-system based router for Zylaris",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc",
|
|
20
|
+
"dev": "tsc --watch",
|
|
21
|
+
"test": "vitest run --passWithNoTests",
|
|
22
|
+
"typecheck": "tsc --noEmit",
|
|
23
|
+
"clean": "rm -rf dist"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@zylaris/reactivity": "workspace:*",
|
|
27
|
+
"glob": "^10.0.0",
|
|
28
|
+
"path-to-regexp": "^6.2.1",
|
|
29
|
+
"zylaris": "workspace:*"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^20.0.0",
|
|
33
|
+
"@types/react": "^18.2.0",
|
|
34
|
+
"react": "^18.2.0",
|
|
35
|
+
"typescript": "^5.3.3",
|
|
36
|
+
"vitest": "^1.2.0"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"router",
|
|
40
|
+
"routing",
|
|
41
|
+
"zylaris"
|
|
42
|
+
],
|
|
43
|
+
"license": "MIT"
|
|
44
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ReactNode, MouseEvent } from 'react';
|
|
4
|
+
import { createContext, useContext, useCallback, useState, useEffect } from 'react';
|
|
5
|
+
import type { RouterContextValue } from './types.js';
|
|
6
|
+
|
|
7
|
+
const RouterContext = createContext<RouterContextValue | null>(null);
|
|
8
|
+
|
|
9
|
+
export function useRouter(): RouterContextValue {
|
|
10
|
+
const context = useContext(RouterContext);
|
|
11
|
+
if (!context) {
|
|
12
|
+
throw new Error('useRouter must be used within RouterProvider');
|
|
13
|
+
}
|
|
14
|
+
return context;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function usePathname(): string {
|
|
18
|
+
return useRouter().pathname;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function useSearchParams(): URLSearchParams {
|
|
22
|
+
const { asPath } = useRouter();
|
|
23
|
+
return new URL(asPath, 'http://localhost').searchParams;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useParams(): Record<string, string> {
|
|
27
|
+
const { query } = useRouter();
|
|
28
|
+
return query;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface LinkProps {
|
|
32
|
+
href: string;
|
|
33
|
+
prefetch?: boolean;
|
|
34
|
+
replace?: boolean;
|
|
35
|
+
scroll?: boolean;
|
|
36
|
+
shallow?: boolean;
|
|
37
|
+
locale?: string;
|
|
38
|
+
children?: ReactNode;
|
|
39
|
+
onClick?: (e: MouseEvent<HTMLAnchorElement>) => void;
|
|
40
|
+
className?: string;
|
|
41
|
+
id?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function Link({
|
|
45
|
+
href,
|
|
46
|
+
prefetch = false,
|
|
47
|
+
replace = false,
|
|
48
|
+
scroll = true,
|
|
49
|
+
children,
|
|
50
|
+
...props
|
|
51
|
+
}: LinkProps): JSX.Element {
|
|
52
|
+
const router = useRouter();
|
|
53
|
+
|
|
54
|
+
const handleClick = useCallback(
|
|
55
|
+
(e: MouseEvent<HTMLAnchorElement>) => {
|
|
56
|
+
if (props.onClick) {
|
|
57
|
+
props.onClick(e);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!e.defaultPrevented) {
|
|
61
|
+
e.preventDefault();
|
|
62
|
+
if (replace) {
|
|
63
|
+
router.replace(href);
|
|
64
|
+
} else {
|
|
65
|
+
router.push(href);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
[href, replace, router, props.onClick]
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (prefetch) {
|
|
74
|
+
router.prefetch(href);
|
|
75
|
+
}
|
|
76
|
+
}, [prefetch, href, router]);
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<a href={href} onClick={handleClick} {...props}>
|
|
80
|
+
{children}
|
|
81
|
+
</a>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface RouterProviderProps {
|
|
86
|
+
children: ReactNode;
|
|
87
|
+
initialPath?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function RouterProvider({
|
|
91
|
+
children,
|
|
92
|
+
initialPath = typeof window !== 'undefined' ? window.location.pathname : '/',
|
|
93
|
+
}: RouterProviderProps): JSX.Element {
|
|
94
|
+
const [pathname, setPathname] = useState(initialPath);
|
|
95
|
+
const [query, _setQuery] = useState<Record<string, string>>({});
|
|
96
|
+
const [asPath, setAsPath] = useState(initialPath);
|
|
97
|
+
|
|
98
|
+
const push = useCallback((url: string) => {
|
|
99
|
+
if (typeof window !== 'undefined') {
|
|
100
|
+
window.history.pushState({}, '', url);
|
|
101
|
+
setPathname(new URL(url, window.location.origin).pathname);
|
|
102
|
+
setAsPath(url);
|
|
103
|
+
}
|
|
104
|
+
}, []);
|
|
105
|
+
|
|
106
|
+
const replace = useCallback((url: string) => {
|
|
107
|
+
if (typeof window !== 'undefined') {
|
|
108
|
+
window.history.replaceState({}, '', url);
|
|
109
|
+
setPathname(new URL(url, window.location.origin).pathname);
|
|
110
|
+
setAsPath(url);
|
|
111
|
+
}
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
const back = useCallback(() => {
|
|
115
|
+
if (typeof window !== 'undefined') {
|
|
116
|
+
window.history.back();
|
|
117
|
+
}
|
|
118
|
+
}, []);
|
|
119
|
+
|
|
120
|
+
const prefetch = useCallback((url: string) => {
|
|
121
|
+
// In real implementation, prefetch route component
|
|
122
|
+
console.log('Prefetching:', url);
|
|
123
|
+
}, []);
|
|
124
|
+
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
const handlePopState = () => {
|
|
127
|
+
setPathname(window.location.pathname);
|
|
128
|
+
setAsPath(window.location.pathname + window.location.search);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
window.addEventListener('popstate', handlePopState);
|
|
132
|
+
return () => window.removeEventListener('popstate', handlePopState);
|
|
133
|
+
}, []);
|
|
134
|
+
|
|
135
|
+
const value: RouterContextValue = {
|
|
136
|
+
pathname,
|
|
137
|
+
query,
|
|
138
|
+
asPath,
|
|
139
|
+
push,
|
|
140
|
+
replace,
|
|
141
|
+
back,
|
|
142
|
+
prefetch,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<RouterContext.Provider value={value}>
|
|
147
|
+
{children}
|
|
148
|
+
</RouterContext.Provider>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { glob } from 'glob';
|
|
2
|
+
import { relative, dirname, basename, extname } from 'path';
|
|
3
|
+
import type { Route, RouterConfig, ParamDefinition } from './types.js';
|
|
4
|
+
|
|
5
|
+
const ROUTE_FILES = ['page', 'layout', 'loading', 'error', 'template', 'not-found', 'route'];
|
|
6
|
+
|
|
7
|
+
export async function generateRoutes(config: RouterConfig): Promise<Route[]> {
|
|
8
|
+
const { appDir, extensions } = config;
|
|
9
|
+
const pattern = `${appDir}/**/*.{${extensions.join(',')}}`;
|
|
10
|
+
const files = await glob(pattern, { absolute: true });
|
|
11
|
+
|
|
12
|
+
const routes: Route[] = [];
|
|
13
|
+
const routeMap = new Map<string, Partial<Route>>();
|
|
14
|
+
|
|
15
|
+
for (const file of files) {
|
|
16
|
+
const routeInfo = await fileToRouteInfo(file, appDir);
|
|
17
|
+
if (!routeInfo) continue;
|
|
18
|
+
|
|
19
|
+
const { path: routePath, type } = routeInfo;
|
|
20
|
+
|
|
21
|
+
if (type === 'page' || type === 'route') {
|
|
22
|
+
const existing = routeMap.get(routePath) || {};
|
|
23
|
+
routeMap.set(routePath, {
|
|
24
|
+
...existing,
|
|
25
|
+
path: routePath,
|
|
26
|
+
filePath: file,
|
|
27
|
+
params: extractParams(routePath),
|
|
28
|
+
isDynamic: routePath.includes(':'),
|
|
29
|
+
isCatchAll: routePath.includes('*'),
|
|
30
|
+
isOptional: routePath.includes('?'),
|
|
31
|
+
});
|
|
32
|
+
} else if (type === 'layout') {
|
|
33
|
+
const existing = routeMap.get(routePath) || {};
|
|
34
|
+
routeMap.set(routePath, {
|
|
35
|
+
...existing,
|
|
36
|
+
path: routePath,
|
|
37
|
+
layout: file,
|
|
38
|
+
});
|
|
39
|
+
} else if (type === 'loading') {
|
|
40
|
+
const existing = routeMap.get(routePath) || {};
|
|
41
|
+
routeMap.set(routePath, {
|
|
42
|
+
...existing,
|
|
43
|
+
path: routePath,
|
|
44
|
+
loading: file,
|
|
45
|
+
});
|
|
46
|
+
} else if (type === 'error') {
|
|
47
|
+
const existing = routeMap.get(routePath) || {};
|
|
48
|
+
routeMap.set(routePath, {
|
|
49
|
+
...existing,
|
|
50
|
+
path: routePath,
|
|
51
|
+
error: file,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Convert map to array of complete routes
|
|
57
|
+
for (const [path, info] of routeMap) {
|
|
58
|
+
if (info.filePath) {
|
|
59
|
+
routes.push({
|
|
60
|
+
path,
|
|
61
|
+
filePath: info.filePath,
|
|
62
|
+
params: info.params || [],
|
|
63
|
+
isDynamic: info.isDynamic || false,
|
|
64
|
+
isCatchAll: info.isCatchAll || false,
|
|
65
|
+
isOptional: info.isOptional || false,
|
|
66
|
+
} as Route);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return routes;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface RouteInfo {
|
|
74
|
+
path: string;
|
|
75
|
+
type: string;
|
|
76
|
+
isLayout: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function fileToRouteInfo(
|
|
80
|
+
filePath: string,
|
|
81
|
+
appDir: string
|
|
82
|
+
): Promise<RouteInfo | null> {
|
|
83
|
+
const relativePath = relative(appDir, filePath);
|
|
84
|
+
const ext = extname(relativePath);
|
|
85
|
+
const base = basename(relativePath, ext);
|
|
86
|
+
const dir = dirname(relativePath);
|
|
87
|
+
|
|
88
|
+
// Skip non-route files
|
|
89
|
+
if (base.startsWith('_') || base.startsWith('.')) return null;
|
|
90
|
+
if (!ROUTE_FILES.includes(base)) return null;
|
|
91
|
+
|
|
92
|
+
// Build route path
|
|
93
|
+
let routePath: string;
|
|
94
|
+
|
|
95
|
+
if (base === 'page' || base === 'layout' || base === 'loading' || base === 'error') {
|
|
96
|
+
// Convert file path to route path
|
|
97
|
+
const segments = dir.split('/').filter(Boolean);
|
|
98
|
+
|
|
99
|
+
// Handle route groups (parentheses)
|
|
100
|
+
const filteredSegments = segments.filter(
|
|
101
|
+
s => !s.startsWith('(') || !s.endsWith(')')
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
// Convert dynamic segments
|
|
105
|
+
const routeSegments = filteredSegments.map(segment => {
|
|
106
|
+
if (segment.startsWith('[') && segment.endsWith(']')) {
|
|
107
|
+
const paramName = segment.slice(1, -1);
|
|
108
|
+
if (paramName.startsWith('...')) {
|
|
109
|
+
return `:${paramName.slice(3)}*`; // Catch-all
|
|
110
|
+
}
|
|
111
|
+
if (paramName.endsWith('?')) {
|
|
112
|
+
return `:${paramName.slice(0, -1)}?`; // Optional
|
|
113
|
+
}
|
|
114
|
+
return `:${paramName}`; // Required dynamic
|
|
115
|
+
}
|
|
116
|
+
return segment;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
routePath = '/' + routeSegments.join('/');
|
|
120
|
+
if (routePath === '/') routePath = '/';
|
|
121
|
+
} else if (base === 'route') {
|
|
122
|
+
// API route
|
|
123
|
+
const segments = dir.split('/').filter(Boolean);
|
|
124
|
+
routePath = '/api/' + segments.join('/');
|
|
125
|
+
} else {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
path: routePath,
|
|
131
|
+
type: base,
|
|
132
|
+
isLayout: base === 'layout',
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function extractParams(path: string): ParamDefinition[] {
|
|
137
|
+
const params: ParamDefinition[] = [];
|
|
138
|
+
const paramRegex = /:(\w+)(\?|\*)?/g;
|
|
139
|
+
let match;
|
|
140
|
+
|
|
141
|
+
while ((match = paramRegex.exec(path)) !== null) {
|
|
142
|
+
const name = match[1];
|
|
143
|
+
const modifier = match[2];
|
|
144
|
+
|
|
145
|
+
params.push({
|
|
146
|
+
name,
|
|
147
|
+
optional: modifier === '?',
|
|
148
|
+
type: modifier === '*' ? 'catch-all' : 'string',
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return params;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Watch for route changes
|
|
156
|
+
export function watchRoutes(
|
|
157
|
+
_config: RouterConfig,
|
|
158
|
+
_onChange: (routes: Route[]) => void
|
|
159
|
+
): () => void {
|
|
160
|
+
// In real implementation, use chokidar or fs.watch
|
|
161
|
+
// For now, just return cleanup function
|
|
162
|
+
return () => {};
|
|
163
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Zylaris Router - File-system based routing
|
|
2
|
+
|
|
3
|
+
export { Router, createRouter } from './router.js';
|
|
4
|
+
export { generateRoutes, watchRoutes } from './fs-router.js';
|
|
5
|
+
export {
|
|
6
|
+
Link,
|
|
7
|
+
RouterProvider,
|
|
8
|
+
useRouter,
|
|
9
|
+
usePathname,
|
|
10
|
+
useSearchParams,
|
|
11
|
+
useParams,
|
|
12
|
+
} from './components.js';
|
|
13
|
+
|
|
14
|
+
export type {
|
|
15
|
+
Route,
|
|
16
|
+
RouteMatch,
|
|
17
|
+
ParamDefinition,
|
|
18
|
+
RouterConfig,
|
|
19
|
+
RouteModule,
|
|
20
|
+
Metadata,
|
|
21
|
+
RouterContextValue,
|
|
22
|
+
} from './types.js';
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { createRouter } from './router.js';
|
|
3
|
+
import type { Route } from './types.js';
|
|
4
|
+
|
|
5
|
+
describe('Router', () => {
|
|
6
|
+
const createTestRoutes = (): Route[] => [
|
|
7
|
+
{ path: '/', filePath: 'page.tsx', params: [], isDynamic: false, isCatchAll: false, isOptional: false },
|
|
8
|
+
{ path: '/about', filePath: 'about/page.tsx', params: [], isDynamic: false, isCatchAll: false, isOptional: false },
|
|
9
|
+
{ path: '/users/:id', filePath: 'users/[id]/page.tsx', params: [{ name: 'id', optional: false, type: 'string' }], isDynamic: true, isCatchAll: false, isOptional: false },
|
|
10
|
+
{ path: '/posts/:slug?', filePath: 'posts/[[slug]]/page.tsx', params: [{ name: 'slug', optional: true, type: 'string' }], isDynamic: true, isCatchAll: false, isOptional: true },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
it('should create router with routes', () => {
|
|
14
|
+
const routes = createTestRoutes();
|
|
15
|
+
const router = createRouter(routes);
|
|
16
|
+
|
|
17
|
+
expect(router.getRoutes()).toHaveLength(4);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should match static route', () => {
|
|
21
|
+
const routes = createTestRoutes();
|
|
22
|
+
const router = createRouter(routes);
|
|
23
|
+
|
|
24
|
+
const match = router.match('/about');
|
|
25
|
+
expect(match).not.toBeNull();
|
|
26
|
+
expect(match?.route.path).toBe('/about');
|
|
27
|
+
expect(match?.params).toEqual({});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should match root route', () => {
|
|
31
|
+
const routes = createTestRoutes();
|
|
32
|
+
const router = createRouter(routes);
|
|
33
|
+
|
|
34
|
+
const match = router.match('/');
|
|
35
|
+
expect(match).not.toBeNull();
|
|
36
|
+
expect(match?.route.path).toBe('/');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should match dynamic route', () => {
|
|
40
|
+
const routes = createTestRoutes();
|
|
41
|
+
const router = createRouter(routes);
|
|
42
|
+
|
|
43
|
+
const match = router.match('/users/123');
|
|
44
|
+
expect(match).not.toBeNull();
|
|
45
|
+
expect(match?.route.path).toBe('/users/:id');
|
|
46
|
+
expect(match?.params).toEqual({ id: '123' });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should return null for non-matching route', () => {
|
|
50
|
+
const routes = createTestRoutes();
|
|
51
|
+
const router = createRouter(routes);
|
|
52
|
+
|
|
53
|
+
const match = router.match('/nonexistent');
|
|
54
|
+
expect(match).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should cache route matches', () => {
|
|
58
|
+
const routes = createTestRoutes();
|
|
59
|
+
const router = createRouter(routes);
|
|
60
|
+
|
|
61
|
+
const match1 = router.match('/about');
|
|
62
|
+
const match2 = router.match('/about');
|
|
63
|
+
|
|
64
|
+
// Should return same cached result
|
|
65
|
+
expect(match1).toBe(match2);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should add route', () => {
|
|
69
|
+
const router = createRouter();
|
|
70
|
+
|
|
71
|
+
router.addRoute({
|
|
72
|
+
path: '/new',
|
|
73
|
+
filePath: 'new/page.tsx',
|
|
74
|
+
params: [],
|
|
75
|
+
isDynamic: false,
|
|
76
|
+
isCatchAll: false,
|
|
77
|
+
isOptional: false
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(router.getRoutes()).toHaveLength(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should remove route', () => {
|
|
84
|
+
const routes = createTestRoutes();
|
|
85
|
+
const router = createRouter(routes);
|
|
86
|
+
|
|
87
|
+
router.removeRoute('/about');
|
|
88
|
+
|
|
89
|
+
expect(router.getRoutes()).toHaveLength(3);
|
|
90
|
+
expect(router.match('/about')).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should generate path from route', () => {
|
|
94
|
+
const router = createRouter();
|
|
95
|
+
|
|
96
|
+
const path = router.generatePath('/users/:id', { id: '123' });
|
|
97
|
+
expect(path).toBe('/users/123');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should clear cache', () => {
|
|
101
|
+
const routes = createTestRoutes();
|
|
102
|
+
const router = createRouter(routes);
|
|
103
|
+
|
|
104
|
+
router.match('/about');
|
|
105
|
+
router.clearCache();
|
|
106
|
+
|
|
107
|
+
// Cache is cleared, should still work
|
|
108
|
+
const match = router.match('/about');
|
|
109
|
+
expect(match).not.toBeNull();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { pathToRegexp, compile, Key } from 'path-to-regexp';
|
|
2
|
+
import type { Route, RouteMatch } from './types.js';
|
|
3
|
+
|
|
4
|
+
export class Router {
|
|
5
|
+
private routes: Route[] = [];
|
|
6
|
+
private routeCache: Map<string, RouteMatch> = new Map();
|
|
7
|
+
|
|
8
|
+
constructor(routes: Route[] = []) {
|
|
9
|
+
this.routes = this.sortRoutes(routes);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
addRoute(route: Route): void {
|
|
13
|
+
this.routes.push(route);
|
|
14
|
+
this.routes = this.sortRoutes(this.routes);
|
|
15
|
+
this.routeCache.clear();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
removeRoute(path: string): void {
|
|
19
|
+
this.routes = this.routes.filter(r => r.path !== path);
|
|
20
|
+
this.routeCache.clear();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
match(path: string): RouteMatch | null {
|
|
24
|
+
// Check cache
|
|
25
|
+
if (this.routeCache.has(path)) {
|
|
26
|
+
return this.routeCache.get(path)!;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const url = new URL(path, 'http://localhost');
|
|
30
|
+
const pathname = url.pathname;
|
|
31
|
+
|
|
32
|
+
for (const route of this.routes) {
|
|
33
|
+
const match = this.matchRoute(route, pathname);
|
|
34
|
+
if (match) {
|
|
35
|
+
const result: RouteMatch = {
|
|
36
|
+
route,
|
|
37
|
+
params: match,
|
|
38
|
+
searchParams: url.searchParams,
|
|
39
|
+
};
|
|
40
|
+
this.routeCache.set(path, result);
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private matchRoute(
|
|
49
|
+
route: Route,
|
|
50
|
+
pathname: string
|
|
51
|
+
): Record<string, string> | null {
|
|
52
|
+
try {
|
|
53
|
+
const keys: Key[] = [];
|
|
54
|
+
const regexp = pathToRegexp(route.path, keys);
|
|
55
|
+
const match = regexp.exec(pathname);
|
|
56
|
+
|
|
57
|
+
if (!match) return null;
|
|
58
|
+
|
|
59
|
+
const params: Record<string, string> = {};
|
|
60
|
+
keys.forEach((key, i) => {
|
|
61
|
+
params[key.name] = match[i + 1];
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return params;
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
generatePath(routePath: string, params: Record<string, string>): string {
|
|
71
|
+
try {
|
|
72
|
+
const toPath = compile(routePath);
|
|
73
|
+
return toPath(params);
|
|
74
|
+
} catch {
|
|
75
|
+
return routePath;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private sortRoutes(routes: Route[]): Route[] {
|
|
80
|
+
// Sort by specificity: static > dynamic > catch-all
|
|
81
|
+
return [...routes].sort((a, b) => {
|
|
82
|
+
// Static routes first
|
|
83
|
+
const aStatic = a.path.split('/').filter(p => !p.includes(':')).length;
|
|
84
|
+
const bStatic = b.path.split('/').filter(p => !p.includes(':')).length;
|
|
85
|
+
if (aStatic !== bStatic) return bStatic - aStatic;
|
|
86
|
+
|
|
87
|
+
// Then by number of dynamic segments (fewer first)
|
|
88
|
+
const aDynamic = (a.path.match(/:/g) || []).length;
|
|
89
|
+
const bDynamic = (b.path.match(/:/g) || []).length;
|
|
90
|
+
if (aDynamic !== bDynamic) return aDynamic - bDynamic;
|
|
91
|
+
|
|
92
|
+
// Catch-all routes last
|
|
93
|
+
if (a.isCatchAll && !b.isCatchAll) return 1;
|
|
94
|
+
if (!a.isCatchAll && b.isCatchAll) return -1;
|
|
95
|
+
|
|
96
|
+
return 0;
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
getRoutes(): Route[] {
|
|
101
|
+
return [...this.routes];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
clearCache(): void {
|
|
105
|
+
this.routeCache.clear();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Create router instance
|
|
110
|
+
export function createRouter(routes?: Route[]): Router {
|
|
111
|
+
return new Router(routes);
|
|
112
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
type ComponentType = unknown;
|
|
2
|
+
|
|
3
|
+
export interface Route {
|
|
4
|
+
path: string;
|
|
5
|
+
filePath: string;
|
|
6
|
+
component?: ComponentType;
|
|
7
|
+
layout?: ComponentType;
|
|
8
|
+
loading?: ComponentType;
|
|
9
|
+
error?: ComponentType;
|
|
10
|
+
params: ParamDefinition[];
|
|
11
|
+
isDynamic: boolean;
|
|
12
|
+
isCatchAll: boolean;
|
|
13
|
+
isOptional: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ParamDefinition {
|
|
17
|
+
name: string;
|
|
18
|
+
optional: boolean;
|
|
19
|
+
type: 'string' | 'number' | 'catch-all';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RouteMatch {
|
|
23
|
+
route: Route;
|
|
24
|
+
params: Record<string, string>;
|
|
25
|
+
searchParams: URLSearchParams;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RouterConfig {
|
|
29
|
+
appDir: string;
|
|
30
|
+
extensions: string[];
|
|
31
|
+
trailingSlash?: boolean;
|
|
32
|
+
caseSensitive?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RouteModule {
|
|
36
|
+
default?: ComponentType;
|
|
37
|
+
layout?: ComponentType;
|
|
38
|
+
loading?: ComponentType;
|
|
39
|
+
error?: ComponentType;
|
|
40
|
+
generateStaticParams?: () => Promise<Record<string, string>[]>;
|
|
41
|
+
metadata?: Metadata;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface Metadata {
|
|
45
|
+
title?: string;
|
|
46
|
+
description?: string;
|
|
47
|
+
keywords?: string[];
|
|
48
|
+
openGraph?: {
|
|
49
|
+
title?: string;
|
|
50
|
+
description?: string;
|
|
51
|
+
images?: string[];
|
|
52
|
+
};
|
|
53
|
+
twitter?: {
|
|
54
|
+
card?: 'summary' | 'summary_large_image';
|
|
55
|
+
title?: string;
|
|
56
|
+
description?: string;
|
|
57
|
+
images?: string[];
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface RouterContextValue {
|
|
62
|
+
pathname: string;
|
|
63
|
+
query: Record<string, string>;
|
|
64
|
+
asPath: string;
|
|
65
|
+
push: (url: string) => void;
|
|
66
|
+
replace: (url: string) => void;
|
|
67
|
+
back: () => void;
|
|
68
|
+
prefetch: (url: string) => void;
|
|
69
|
+
}
|