@magneticjs/server 0.1.0
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/README.md +65 -0
- package/package.json +20 -0
- package/src/assets.ts +190 -0
- package/src/error-boundary.ts +63 -0
- package/src/file-router.ts +196 -0
- package/src/index.ts +23 -0
- package/src/jsx-runtime.ts +244 -0
- package/src/middleware.ts +174 -0
- package/src/router.ts +250 -0
- package/src/ssr.ts +232 -0
package/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# @magneticjs/server
|
|
2
|
+
|
|
3
|
+
Server-driven UI framework for Magnetic. Provides the JSX runtime that transforms TSX into JSON DOM descriptors, plus routing and SSR utilities.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @magneticjs/server
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
### JSX Runtime (pages & components)
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
// tsconfig.json: { "jsx": "react-jsx", "jsxImportSource": "@magneticjs/server" }
|
|
17
|
+
import { Head, Link } from '@magneticjs/server/jsx-runtime';
|
|
18
|
+
|
|
19
|
+
export function IndexPage(props: any) {
|
|
20
|
+
return (
|
|
21
|
+
<div key="app">
|
|
22
|
+
<Head><title>My App</title></Head>
|
|
23
|
+
<h1>{props.title}</h1>
|
|
24
|
+
<Link href="/about">About</Link>
|
|
25
|
+
<button onClick="do_something">Click me</button>
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Router
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { createRouter } from '@magneticjs/server/router';
|
|
35
|
+
|
|
36
|
+
const router = createRouter([
|
|
37
|
+
{ path: '/', page: IndexPage },
|
|
38
|
+
{ path: '/about', page: AboutPage },
|
|
39
|
+
{ path: '*', page: NotFoundPage },
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const result = router.resolve('/about', viewModel);
|
|
43
|
+
// → { kind: 'render', dom: DomNode }
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Key Concepts
|
|
47
|
+
|
|
48
|
+
- **DomNode**: JSON DOM descriptor `{ tag, key?, attrs?, events?, text?, children? }`
|
|
49
|
+
- **Events**: `onClick`, `onSubmit`, `onInput` → action names (strings, not callbacks)
|
|
50
|
+
- **Head**: Declares `<title>` and `<meta>` tags for SSR
|
|
51
|
+
- **Link**: Client-side navigation without page reload
|
|
52
|
+
- **Fragment**: Groups children without a wrapper element
|
|
53
|
+
|
|
54
|
+
## Exports
|
|
55
|
+
|
|
56
|
+
| Path | Description |
|
|
57
|
+
|------|-------------|
|
|
58
|
+
| `@magneticjs/server` | Core index |
|
|
59
|
+
| `@magneticjs/server/jsx-runtime` | JSX factory, Head, Link, Fragment, DomNode |
|
|
60
|
+
| `@magneticjs/server/router` | createRouter, route matching |
|
|
61
|
+
| `@magneticjs/server/ssr` | render_page, PageOptions |
|
|
62
|
+
|
|
63
|
+
## License
|
|
64
|
+
|
|
65
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@magneticjs/server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Magnetic server-driven UI framework — JSX runtime, router, SSR",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./jsx-runtime": "./src/jsx-runtime.ts",
|
|
9
|
+
"./router": "./src/router.ts",
|
|
10
|
+
"./ssr": "./src/ssr.ts"
|
|
11
|
+
},
|
|
12
|
+
"files": ["src"],
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"repository": { "type": "git", "url": "https://github.com/inventhq/magnetic.git", "directory": "js/packages/magnetic-server" },
|
|
17
|
+
"homepage": "https://github.com/inventhq/magnetic#readme",
|
|
18
|
+
"keywords": ["magnetic", "server-driven-ui", "jsx", "ssr", "dom-descriptors"],
|
|
19
|
+
"license": "MIT"
|
|
20
|
+
}
|
package/src/assets.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// @magnetic/server — Static Asset Pipeline
|
|
2
|
+
// Content-hashed filenames, cache headers, asset manifest
|
|
3
|
+
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, copyFileSync } from 'node:fs';
|
|
6
|
+
import { join, basename, extname } from 'node:path';
|
|
7
|
+
|
|
8
|
+
// ── Asset manifest ──────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export interface AssetManifest {
|
|
11
|
+
/** Maps original filename → hashed filename */
|
|
12
|
+
files: Record<string, string>;
|
|
13
|
+
/** Reverse map: hashed filename → original filename */
|
|
14
|
+
reverse: Record<string, string>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Builds a content-hashed asset manifest from a source directory.
|
|
19
|
+
* Copies files to outDir with hashed names.
|
|
20
|
+
*
|
|
21
|
+
* e.g. style.css → style.a1b2c3d4.css
|
|
22
|
+
*/
|
|
23
|
+
export function buildAssets(opts: {
|
|
24
|
+
/** Source directory with original files */
|
|
25
|
+
srcDir: string;
|
|
26
|
+
/** Output directory for hashed files */
|
|
27
|
+
outDir: string;
|
|
28
|
+
/** File extensions to hash (default: css, js, wasm) */
|
|
29
|
+
extensions?: string[];
|
|
30
|
+
/** Files to skip hashing (served as-is) */
|
|
31
|
+
passthrough?: string[];
|
|
32
|
+
}): AssetManifest {
|
|
33
|
+
const {
|
|
34
|
+
srcDir,
|
|
35
|
+
outDir,
|
|
36
|
+
extensions = ['.css', '.js', '.wasm'],
|
|
37
|
+
passthrough = [],
|
|
38
|
+
} = opts;
|
|
39
|
+
|
|
40
|
+
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
|
|
41
|
+
|
|
42
|
+
const manifest: AssetManifest = { files: {}, reverse: {} };
|
|
43
|
+
|
|
44
|
+
if (!existsSync(srcDir)) return manifest;
|
|
45
|
+
|
|
46
|
+
const entries = readdirSync(srcDir);
|
|
47
|
+
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
const ext = extname(entry);
|
|
50
|
+
const srcPath = join(srcDir, entry);
|
|
51
|
+
|
|
52
|
+
// Passthrough files — copy without hashing
|
|
53
|
+
if (passthrough.includes(entry)) {
|
|
54
|
+
copyFileSync(srcPath, join(outDir, entry));
|
|
55
|
+
manifest.files[entry] = entry;
|
|
56
|
+
manifest.reverse[entry] = entry;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Only hash configured extensions
|
|
61
|
+
if (!extensions.includes(ext)) {
|
|
62
|
+
copyFileSync(srcPath, join(outDir, entry));
|
|
63
|
+
manifest.files[entry] = entry;
|
|
64
|
+
manifest.reverse[entry] = entry;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Read file and compute content hash
|
|
69
|
+
const content = readFileSync(srcPath);
|
|
70
|
+
const hash = createHash('md5').update(content).digest('hex').slice(0, 8);
|
|
71
|
+
const name = basename(entry, ext);
|
|
72
|
+
const hashedName = `${name}.${hash}${ext}`;
|
|
73
|
+
|
|
74
|
+
copyFileSync(srcPath, join(outDir, hashedName));
|
|
75
|
+
manifest.files[entry] = hashedName;
|
|
76
|
+
manifest.reverse[hashedName] = entry;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return manifest;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Saves manifest to a JSON file for use at runtime.
|
|
84
|
+
*/
|
|
85
|
+
export function saveManifest(manifest: AssetManifest, filePath: string): void {
|
|
86
|
+
writeFileSync(filePath, JSON.stringify(manifest, null, 2));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Loads manifest from a JSON file.
|
|
91
|
+
*/
|
|
92
|
+
export function loadManifest(filePath: string): AssetManifest {
|
|
93
|
+
if (!existsSync(filePath)) return { files: {}, reverse: {} };
|
|
94
|
+
return JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Asset URL resolver ──────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Creates an asset resolver that maps original filenames to hashed URLs.
|
|
101
|
+
*
|
|
102
|
+
* Usage:
|
|
103
|
+
* ```ts
|
|
104
|
+
* const asset = createAssetResolver(manifest, '/static');
|
|
105
|
+
* asset('style.css') // → '/static/style.a1b2c3d4.css'
|
|
106
|
+
* ```
|
|
107
|
+
*/
|
|
108
|
+
export function createAssetResolver(
|
|
109
|
+
manifest: AssetManifest,
|
|
110
|
+
prefix: string = '',
|
|
111
|
+
): (filename: string) => string {
|
|
112
|
+
return (filename: string) => {
|
|
113
|
+
const hashed = manifest.files[filename];
|
|
114
|
+
return prefix + '/' + (hashed || filename);
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Serve static with cache headers ─────────────────────────────────
|
|
119
|
+
|
|
120
|
+
export interface StaticFileResult {
|
|
121
|
+
found: boolean;
|
|
122
|
+
content?: Buffer;
|
|
123
|
+
contentType?: string;
|
|
124
|
+
headers?: Record<string, string>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const MIME: Record<string, string> = {
|
|
128
|
+
'.html': 'text/html; charset=utf-8',
|
|
129
|
+
'.js': 'application/javascript',
|
|
130
|
+
'.css': 'text/css',
|
|
131
|
+
'.json': 'application/json',
|
|
132
|
+
'.wasm': 'application/wasm',
|
|
133
|
+
'.png': 'image/png',
|
|
134
|
+
'.jpg': 'image/jpeg',
|
|
135
|
+
'.jpeg': 'image/jpeg',
|
|
136
|
+
'.gif': 'image/gif',
|
|
137
|
+
'.svg': 'image/svg+xml',
|
|
138
|
+
'.ico': 'image/x-icon',
|
|
139
|
+
'.webp': 'image/webp',
|
|
140
|
+
'.woff': 'font/woff',
|
|
141
|
+
'.woff2': 'font/woff2',
|
|
142
|
+
'.ttf': 'font/ttf',
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Serves a static file with appropriate cache headers.
|
|
147
|
+
* Content-hashed files get immutable cache (1 year).
|
|
148
|
+
* Non-hashed files get short cache (5 min) with revalidation.
|
|
149
|
+
*/
|
|
150
|
+
export function serveStatic(
|
|
151
|
+
dir: string,
|
|
152
|
+
urlPath: string,
|
|
153
|
+
manifest?: AssetManifest,
|
|
154
|
+
): StaticFileResult {
|
|
155
|
+
// Strip leading slash
|
|
156
|
+
const filename = urlPath.replace(/^\//, '');
|
|
157
|
+
const filePath = join(dir, filename);
|
|
158
|
+
|
|
159
|
+
// Security: prevent path traversal
|
|
160
|
+
if (!filePath.startsWith(dir)) {
|
|
161
|
+
return { found: false };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!existsSync(filePath)) {
|
|
165
|
+
return { found: false };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const ext = extname(filename);
|
|
169
|
+
const contentType = MIME[ext] || 'application/octet-stream';
|
|
170
|
+
const content = readFileSync(filePath);
|
|
171
|
+
|
|
172
|
+
// Determine cache strategy
|
|
173
|
+
const isHashed = manifest?.reverse[filename] != null
|
|
174
|
+
&& manifest.reverse[filename] !== filename;
|
|
175
|
+
|
|
176
|
+
const headers: Record<string, string> = {
|
|
177
|
+
'Content-Type': contentType,
|
|
178
|
+
'Content-Length': String(content.length),
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
if (isHashed) {
|
|
182
|
+
// Immutable — content-hashed, safe to cache forever
|
|
183
|
+
headers['Cache-Control'] = 'public, max-age=31536000, immutable';
|
|
184
|
+
} else {
|
|
185
|
+
// Short cache with revalidation
|
|
186
|
+
headers['Cache-Control'] = 'public, max-age=300, must-revalidate';
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { found: true, content, contentType, headers };
|
|
190
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// @magnetic/server — Error Boundaries
|
|
2
|
+
// Wraps render/reduce in try-catch with fallback UI
|
|
3
|
+
|
|
4
|
+
import type { DomNode } from './jsx-runtime.ts';
|
|
5
|
+
|
|
6
|
+
export interface ErrorFallbackProps {
|
|
7
|
+
error: Error;
|
|
8
|
+
path?: string;
|
|
9
|
+
action?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type ErrorFallback = (props: ErrorFallbackProps) => DomNode;
|
|
13
|
+
|
|
14
|
+
/** Default fallback — minimal error display */
|
|
15
|
+
export const defaultFallback: ErrorFallback = ({ error, action }) => ({
|
|
16
|
+
tag: 'div',
|
|
17
|
+
key: 'error-boundary',
|
|
18
|
+
attrs: { class: 'magnetic-error' },
|
|
19
|
+
children: [
|
|
20
|
+
{ tag: 'h2', key: 'err-h', text: 'Something went wrong' },
|
|
21
|
+
{ tag: 'p', key: 'err-msg', text: error.message },
|
|
22
|
+
...(action ? [{ tag: 'p', key: 'err-act', text: `Action: ${action}` }] : []),
|
|
23
|
+
],
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Wraps a render function with error handling.
|
|
28
|
+
* If render throws, the fallback component is returned instead.
|
|
29
|
+
*/
|
|
30
|
+
export function withErrorBoundary<T extends (...args: any[]) => DomNode>(
|
|
31
|
+
renderFn: T,
|
|
32
|
+
fallback: ErrorFallback = defaultFallback,
|
|
33
|
+
): T {
|
|
34
|
+
return ((...args: any[]) => {
|
|
35
|
+
try {
|
|
36
|
+
return renderFn(...args);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
39
|
+
console.error('[magnetic] render error:', error.message);
|
|
40
|
+
return fallback({ error });
|
|
41
|
+
}
|
|
42
|
+
}) as T;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Wraps a reducer with error handling.
|
|
47
|
+
* If reduce throws, the original state is returned unchanged.
|
|
48
|
+
*/
|
|
49
|
+
export function safeReduce<S>(
|
|
50
|
+
reduceFn: (state: S, action: string, payload: any) => S,
|
|
51
|
+
onError?: (error: Error, action: string) => void,
|
|
52
|
+
): (state: S, action: string, payload: any) => S {
|
|
53
|
+
return (state, action, payload) => {
|
|
54
|
+
try {
|
|
55
|
+
return reduceFn(state, action, payload);
|
|
56
|
+
} catch (err) {
|
|
57
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
58
|
+
console.error(`[magnetic] reduce error on "${action}":`, error.message);
|
|
59
|
+
if (onError) onError(error, action);
|
|
60
|
+
return state;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// @magnetic/server — File-based Router
|
|
2
|
+
// Scans a pages/ directory and generates route definitions from file conventions.
|
|
3
|
+
//
|
|
4
|
+
// Convention:
|
|
5
|
+
// pages/
|
|
6
|
+
// index.tsx → /
|
|
7
|
+
// about.tsx → /about
|
|
8
|
+
// layout.tsx → layout wrapping all routes at this level
|
|
9
|
+
// _guard.ts → guard for all routes at this level
|
|
10
|
+
// tasks/
|
|
11
|
+
// index.tsx → /tasks
|
|
12
|
+
// [id].tsx → /tasks/:id
|
|
13
|
+
// layout.tsx → layout wrapping /tasks/* routes
|
|
14
|
+
// _guard.ts → guard for /tasks/* routes
|
|
15
|
+
//
|
|
16
|
+
// Exports `scanPages()` which returns a RouteDefinition[] tree.
|
|
17
|
+
// This runs at build time or server startup — not at request time.
|
|
18
|
+
|
|
19
|
+
import { readdirSync, statSync, existsSync } from 'node:fs';
|
|
20
|
+
import { join, basename, extname } from 'node:path';
|
|
21
|
+
import type { RouteDefinition, PageComponent, LayoutComponent, RouteGuard } from './router.ts';
|
|
22
|
+
|
|
23
|
+
export interface FileRouterOptions {
|
|
24
|
+
/** Absolute path to the pages/ directory */
|
|
25
|
+
pagesDir: string;
|
|
26
|
+
/** Function to import a module given its file path. Default: dynamic import */
|
|
27
|
+
importFn?: (filePath: string) => any;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ScannedModule {
|
|
31
|
+
default?: PageComponent | LayoutComponent | RouteGuard;
|
|
32
|
+
guard?: RouteGuard;
|
|
33
|
+
layout?: LayoutComponent;
|
|
34
|
+
redirect?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Scans a pages/ directory and returns route definitions.
|
|
39
|
+
*
|
|
40
|
+
* Each .tsx file becomes a route. The default export is the page component.
|
|
41
|
+
* Special files:
|
|
42
|
+
* - `layout.tsx` → layout component (default export)
|
|
43
|
+
* - `_guard.ts` → route guard (default or named `guard` export)
|
|
44
|
+
*
|
|
45
|
+
* Dynamic params use bracket syntax: `[id].tsx` → `:id`
|
|
46
|
+
*/
|
|
47
|
+
export function scanPages(
|
|
48
|
+
pagesDir: string,
|
|
49
|
+
modules: Record<string, ScannedModule>,
|
|
50
|
+
): RouteDefinition[] {
|
|
51
|
+
if (!existsSync(pagesDir)) return [];
|
|
52
|
+
return scanDir(pagesDir, '', modules);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function scanDir(
|
|
56
|
+
dir: string,
|
|
57
|
+
pathPrefix: string,
|
|
58
|
+
modules: Record<string, ScannedModule>,
|
|
59
|
+
): RouteDefinition[] {
|
|
60
|
+
const entries = readdirSync(dir).sort();
|
|
61
|
+
const routes: RouteDefinition[] = [];
|
|
62
|
+
|
|
63
|
+
// Check for layout and guard at this level
|
|
64
|
+
let layout: LayoutComponent | undefined;
|
|
65
|
+
let guard: RouteGuard | undefined;
|
|
66
|
+
|
|
67
|
+
const layoutFile = findFile(dir, 'layout');
|
|
68
|
+
if (layoutFile) {
|
|
69
|
+
const mod = modules[layoutFile];
|
|
70
|
+
if (mod) layout = (mod.default || mod.layout) as LayoutComponent;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const guardFile = findFile(dir, '_guard');
|
|
74
|
+
if (guardFile) {
|
|
75
|
+
const mod = modules[guardFile];
|
|
76
|
+
if (mod) guard = (mod.default || mod.guard) as RouteGuard;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Scan files and subdirectories
|
|
80
|
+
const files: string[] = [];
|
|
81
|
+
const dirs: string[] = [];
|
|
82
|
+
|
|
83
|
+
for (const entry of entries) {
|
|
84
|
+
const full = join(dir, entry);
|
|
85
|
+
const stat = statSync(full);
|
|
86
|
+
if (stat.isDirectory()) {
|
|
87
|
+
dirs.push(entry);
|
|
88
|
+
} else if (/\.(tsx?|jsx?)$/.test(entry)) {
|
|
89
|
+
const name = basename(entry, extname(entry));
|
|
90
|
+
// Skip special files
|
|
91
|
+
if (name === 'layout' || name === '_guard') continue;
|
|
92
|
+
files.push(entry);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Process page files
|
|
97
|
+
for (const file of files) {
|
|
98
|
+
const name = basename(file, extname(file));
|
|
99
|
+
const filePath = join(dir, file);
|
|
100
|
+
const mod = modules[filePath];
|
|
101
|
+
if (!mod || !mod.default) continue;
|
|
102
|
+
|
|
103
|
+
let segment: string;
|
|
104
|
+
if (name === 'index') {
|
|
105
|
+
segment = pathPrefix || '/';
|
|
106
|
+
} else if (name.startsWith('[') && name.endsWith(']')) {
|
|
107
|
+
// Dynamic param: [id].tsx → :id
|
|
108
|
+
const param = name.slice(1, -1);
|
|
109
|
+
segment = pathPrefix + '/:' + param;
|
|
110
|
+
} else {
|
|
111
|
+
segment = pathPrefix + '/' + name;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const route: RouteDefinition = {
|
|
115
|
+
path: segment,
|
|
116
|
+
page: mod.default as PageComponent,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
if (mod.redirect) route.redirect = mod.redirect;
|
|
120
|
+
|
|
121
|
+
routes.push(route);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Process subdirectories (nested routes)
|
|
125
|
+
for (const dirName of dirs) {
|
|
126
|
+
if (dirName.startsWith('.') || dirName === 'node_modules') continue;
|
|
127
|
+
|
|
128
|
+
const subDir = join(dir, dirName);
|
|
129
|
+
let segment: string;
|
|
130
|
+
if (dirName.startsWith('[') && dirName.endsWith(']')) {
|
|
131
|
+
const param = dirName.slice(1, -1);
|
|
132
|
+
segment = pathPrefix + '/:' + param;
|
|
133
|
+
} else {
|
|
134
|
+
segment = pathPrefix + '/' + dirName;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const children = scanDir(subDir, segment, modules);
|
|
138
|
+
|
|
139
|
+
if (children.length > 0) {
|
|
140
|
+
// Check if any child is the index for this path
|
|
141
|
+
const indexIdx = children.findIndex((c) => c.path === segment);
|
|
142
|
+
|
|
143
|
+
if (indexIdx >= 0 || layout || guard) {
|
|
144
|
+
// Create a parent route node with children
|
|
145
|
+
const parentRoute: RouteDefinition = {
|
|
146
|
+
path: segment,
|
|
147
|
+
children,
|
|
148
|
+
};
|
|
149
|
+
if (layout) parentRoute.layout = layout;
|
|
150
|
+
if (guard) parentRoute.guard = guard;
|
|
151
|
+
|
|
152
|
+
// If there's an index page, lift it to be the parent's page
|
|
153
|
+
if (indexIdx >= 0) {
|
|
154
|
+
parentRoute.page = children[indexIdx].page;
|
|
155
|
+
children.splice(indexIdx, 1);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
routes.push(parentRoute);
|
|
159
|
+
} else {
|
|
160
|
+
// No layout/guard — flatten children into current level
|
|
161
|
+
routes.push(...children);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// If this is the root level and we have a layout/guard, wrap everything
|
|
167
|
+
if (pathPrefix === '' && (layout || guard)) {
|
|
168
|
+
return [{
|
|
169
|
+
path: '/',
|
|
170
|
+
layout,
|
|
171
|
+
guard,
|
|
172
|
+
children: routes,
|
|
173
|
+
}];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return routes;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function findFile(dir: string, name: string): string | null {
|
|
180
|
+
for (const ext of ['.tsx', '.ts', '.jsx', '.js']) {
|
|
181
|
+
const full = join(dir, name + ext);
|
|
182
|
+
if (existsSync(full)) return full;
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Helper: converts a pages/ directory path to a route path segment.
|
|
189
|
+
* e.g. "[id]" → ":id", "about" → "about"
|
|
190
|
+
*/
|
|
191
|
+
export function fileNameToSegment(name: string): string {
|
|
192
|
+
if (name.startsWith('[') && name.endsWith(']')) {
|
|
193
|
+
return ':' + name.slice(1, -1);
|
|
194
|
+
}
|
|
195
|
+
return name;
|
|
196
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// @magnetic/server — Magnetic Server Components
|
|
2
|
+
// Re-exports for developer use
|
|
3
|
+
|
|
4
|
+
export type { DomNode } from './jsx-runtime.ts';
|
|
5
|
+
export { Link, Head } from './jsx-runtime.ts';
|
|
6
|
+
|
|
7
|
+
export { createRouter, renderRoute, navigateAction } from './router.ts';
|
|
8
|
+
export type { Router, RouteDefinition, RouteMatch, RouteResult, RouteGuard, PageComponent, LayoutComponent } from './router.ts';
|
|
9
|
+
|
|
10
|
+
export { scanPages, fileNameToSegment } from './file-router.ts';
|
|
11
|
+
export type { FileRouterOptions } from './file-router.ts';
|
|
12
|
+
|
|
13
|
+
export { renderToHTML, renderPage, extractHead } from './ssr.ts';
|
|
14
|
+
export type { PageOptions, ExtractedHead } from './ssr.ts';
|
|
15
|
+
|
|
16
|
+
export { withErrorBoundary, safeReduce, defaultFallback } from './error-boundary.ts';
|
|
17
|
+
export type { ErrorFallback, ErrorFallbackProps } from './error-boundary.ts';
|
|
18
|
+
|
|
19
|
+
export { createMiddleware, createContext, loggerMiddleware, corsMiddleware, rateLimitMiddleware } from './middleware.ts';
|
|
20
|
+
export type { MiddlewareStack, MiddlewareFn, MagneticContext, NextFn } from './middleware.ts';
|
|
21
|
+
|
|
22
|
+
export { buildAssets, saveManifest, loadManifest, createAssetResolver, serveStatic } from './assets.ts';
|
|
23
|
+
export type { AssetManifest, StaticFileResult } from './assets.ts';
|