@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
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// @magnetic/server JSX runtime
|
|
2
|
+
// Transforms TSX → DomNode (JSON DOM descriptors for Magnetic)
|
|
3
|
+
|
|
4
|
+
export interface DomNode {
|
|
5
|
+
tag: string;
|
|
6
|
+
key?: string;
|
|
7
|
+
attrs?: Record<string, string>;
|
|
8
|
+
events?: Record<string, string>;
|
|
9
|
+
text?: string;
|
|
10
|
+
children?: DomNode[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type Child = DomNode | string | number | boolean | null | undefined | Child[];
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
key?: string;
|
|
17
|
+
children?: Child | Child[];
|
|
18
|
+
[prop: string]: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type Component = (props: any) => DomNode;
|
|
22
|
+
|
|
23
|
+
// ── Built-in components ─────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Client-side navigation link. Renders an <a> that magnetic.js intercepts
|
|
27
|
+
* to do pushState + send navigate action (no full page reload).
|
|
28
|
+
*/
|
|
29
|
+
export function Link(props: { href: string; children?: Child | Child[]; class?: string; [k: string]: unknown }): DomNode {
|
|
30
|
+
const { href, children, ...rest } = props;
|
|
31
|
+
return jsx('a', { ...rest, href, onClick: `navigate:${href}`, children }, undefined);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Declares <head> elements (title, meta, link, etc.) from within a page component.
|
|
36
|
+
* During SSR, these are extracted and placed into the document <head>.
|
|
37
|
+
* During live updates, they are ignored (head is static after SSR).
|
|
38
|
+
*
|
|
39
|
+
* Usage:
|
|
40
|
+
* <Head><title>My Page</title><meta name="description" content="..." /></Head>
|
|
41
|
+
*/
|
|
42
|
+
export function Head({ children }: { children?: Child | Child[] }): DomNode {
|
|
43
|
+
const flat = flattenChildren(children);
|
|
44
|
+
return { tag: 'magnetic:head', children: flat };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Event prop prefix → event name mapping
|
|
48
|
+
const EVENT_MAP: Record<string, string> = {
|
|
49
|
+
onClick: 'click',
|
|
50
|
+
onSubmit: 'submit',
|
|
51
|
+
onInput: 'input',
|
|
52
|
+
onChange: 'change',
|
|
53
|
+
onFocus: 'focus',
|
|
54
|
+
onBlur: 'blur',
|
|
55
|
+
onKeyDown: 'keydown',
|
|
56
|
+
onKeyUp: 'keyup',
|
|
57
|
+
onScroll: 'scroll',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function flattenChildren(raw: Child | Child[]): DomNode[] {
|
|
61
|
+
if (raw == null || raw === false || raw === true) return [];
|
|
62
|
+
if (typeof raw === 'string' || typeof raw === 'number') {
|
|
63
|
+
return [{ tag: 'span', text: String(raw) }];
|
|
64
|
+
}
|
|
65
|
+
if (Array.isArray(raw)) {
|
|
66
|
+
const out: DomNode[] = [];
|
|
67
|
+
for (const c of raw) {
|
|
68
|
+
out.push(...flattenChildren(c));
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
return [raw as DomNode];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function jsx(tag: string | Component, props: Props, key?: string | number): DomNode {
|
|
76
|
+
const { children, ...rest } = props;
|
|
77
|
+
|
|
78
|
+
// Component function — call it with props (including children)
|
|
79
|
+
if (typeof tag === 'function') {
|
|
80
|
+
return tag({ ...rest, key, children });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// HTML element
|
|
84
|
+
const node: DomNode = { tag };
|
|
85
|
+
if (key != null) node.key = String(key);
|
|
86
|
+
|
|
87
|
+
const attrs: Record<string, string> = {};
|
|
88
|
+
const events: Record<string, string> = {};
|
|
89
|
+
|
|
90
|
+
for (const [k, v] of Object.entries(rest)) {
|
|
91
|
+
if (v == null || v === false) continue;
|
|
92
|
+
|
|
93
|
+
// Event props
|
|
94
|
+
if (EVENT_MAP[k]) {
|
|
95
|
+
events[EVENT_MAP[k]] = String(v);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// class prop → attrs.class
|
|
100
|
+
if (k === 'class' || k === 'className') {
|
|
101
|
+
const cls = String(v).trim();
|
|
102
|
+
if (cls) attrs['class'] = cls;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Boolean attributes
|
|
107
|
+
if (v === true) {
|
|
108
|
+
attrs[k] = '';
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
attrs[k] = String(v);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (Object.keys(attrs).length) node.attrs = attrs;
|
|
116
|
+
if (Object.keys(events).length) node.events = events;
|
|
117
|
+
|
|
118
|
+
// Children
|
|
119
|
+
if (children != null) {
|
|
120
|
+
// Single string/number child → text property (no wrapper span)
|
|
121
|
+
if (typeof children === 'string' || typeof children === 'number') {
|
|
122
|
+
node.text = String(children);
|
|
123
|
+
} else {
|
|
124
|
+
const flat = flattenChildren(children);
|
|
125
|
+
// If all children are text spans, merge into single text
|
|
126
|
+
if (flat.length === 1 && flat[0].tag === 'span' && flat[0].text != null && !flat[0].key) {
|
|
127
|
+
node.text = flat[0].text;
|
|
128
|
+
} else if (flat.length > 0) {
|
|
129
|
+
node.children = flat;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return node;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// jsxs = jsx with static children array (same implementation, key is 3rd arg)
|
|
138
|
+
export const jsxs = jsx;
|
|
139
|
+
|
|
140
|
+
// Fragment — returns children as-is (for use inside other elements)
|
|
141
|
+
export function Fragment({ children }: { children?: Child | Child[] }): DomNode {
|
|
142
|
+
const flat = flattenChildren(children);
|
|
143
|
+
if (flat.length === 1) return flat[0];
|
|
144
|
+
return { tag: 'div', children: flat };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── JSX namespace for TypeScript ────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
type Booleanish = boolean | 'true' | 'false';
|
|
150
|
+
|
|
151
|
+
interface HtmlAttributes {
|
|
152
|
+
key?: string | number;
|
|
153
|
+
class?: string;
|
|
154
|
+
className?: string;
|
|
155
|
+
id?: string;
|
|
156
|
+
style?: string;
|
|
157
|
+
tabIndex?: number;
|
|
158
|
+
role?: string;
|
|
159
|
+
title?: string;
|
|
160
|
+
hidden?: Booleanish;
|
|
161
|
+
'data-key'?: string;
|
|
162
|
+
|
|
163
|
+
// Events (action names, not callbacks)
|
|
164
|
+
onClick?: string;
|
|
165
|
+
onSubmit?: string;
|
|
166
|
+
onInput?: string;
|
|
167
|
+
onChange?: string;
|
|
168
|
+
onFocus?: string;
|
|
169
|
+
onBlur?: string;
|
|
170
|
+
onKeyDown?: string;
|
|
171
|
+
onKeyUp?: string;
|
|
172
|
+
onScroll?: string;
|
|
173
|
+
|
|
174
|
+
children?: Child | Child[];
|
|
175
|
+
[attr: string]: unknown;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
interface InputAttributes extends HtmlAttributes {
|
|
179
|
+
type?: string;
|
|
180
|
+
name?: string;
|
|
181
|
+
value?: string;
|
|
182
|
+
placeholder?: string;
|
|
183
|
+
disabled?: boolean;
|
|
184
|
+
readonly?: boolean;
|
|
185
|
+
required?: boolean;
|
|
186
|
+
autocomplete?: string;
|
|
187
|
+
autofocus?: boolean;
|
|
188
|
+
checked?: boolean;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
interface FormAttributes extends HtmlAttributes {
|
|
192
|
+
action?: string;
|
|
193
|
+
method?: string;
|
|
194
|
+
novalidate?: boolean;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export declare namespace JSX {
|
|
198
|
+
type Element = DomNode;
|
|
199
|
+
interface IntrinsicElements {
|
|
200
|
+
div: HtmlAttributes;
|
|
201
|
+
span: HtmlAttributes;
|
|
202
|
+
p: HtmlAttributes;
|
|
203
|
+
h1: HtmlAttributes;
|
|
204
|
+
h2: HtmlAttributes;
|
|
205
|
+
h3: HtmlAttributes;
|
|
206
|
+
h4: HtmlAttributes;
|
|
207
|
+
h5: HtmlAttributes;
|
|
208
|
+
h6: HtmlAttributes;
|
|
209
|
+
a: HtmlAttributes & { href?: string; target?: string; rel?: string };
|
|
210
|
+
button: HtmlAttributes & { type?: string; disabled?: boolean };
|
|
211
|
+
form: FormAttributes;
|
|
212
|
+
input: InputAttributes;
|
|
213
|
+
textarea: HtmlAttributes & { name?: string; placeholder?: string; rows?: number };
|
|
214
|
+
select: HtmlAttributes & { name?: string };
|
|
215
|
+
option: HtmlAttributes & { value?: string; selected?: boolean };
|
|
216
|
+
label: HtmlAttributes & { for?: string };
|
|
217
|
+
img: HtmlAttributes & { src?: string; alt?: string; width?: number; height?: number; loading?: string };
|
|
218
|
+
meta: HtmlAttributes & { name?: string; content?: string; property?: string; charset?: string; 'http-equiv'?: string };
|
|
219
|
+
title: HtmlAttributes;
|
|
220
|
+
link: HtmlAttributes & { rel?: string; href?: string; type?: string; sizes?: string; media?: string };
|
|
221
|
+
ul: HtmlAttributes;
|
|
222
|
+
ol: HtmlAttributes;
|
|
223
|
+
li: HtmlAttributes;
|
|
224
|
+
nav: HtmlAttributes;
|
|
225
|
+
header: HtmlAttributes;
|
|
226
|
+
footer: HtmlAttributes;
|
|
227
|
+
main: HtmlAttributes;
|
|
228
|
+
section: HtmlAttributes;
|
|
229
|
+
article: HtmlAttributes;
|
|
230
|
+
aside: HtmlAttributes;
|
|
231
|
+
strong: HtmlAttributes;
|
|
232
|
+
em: HtmlAttributes;
|
|
233
|
+
code: HtmlAttributes;
|
|
234
|
+
pre: HtmlAttributes;
|
|
235
|
+
hr: HtmlAttributes;
|
|
236
|
+
br: HtmlAttributes;
|
|
237
|
+
table: HtmlAttributes;
|
|
238
|
+
thead: HtmlAttributes;
|
|
239
|
+
tbody: HtmlAttributes;
|
|
240
|
+
tr: HtmlAttributes;
|
|
241
|
+
th: HtmlAttributes;
|
|
242
|
+
td: HtmlAttributes;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// @magnetic/server — Middleware
|
|
2
|
+
// Express-style use() chain with next() pattern for request processing
|
|
3
|
+
|
|
4
|
+
import type { DomNode } from './jsx-runtime.ts';
|
|
5
|
+
|
|
6
|
+
// ── Context ─────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export interface MagneticContext {
|
|
9
|
+
/** HTTP method */
|
|
10
|
+
method: string;
|
|
11
|
+
/** Request URL path */
|
|
12
|
+
path: string;
|
|
13
|
+
/** Parsed query params */
|
|
14
|
+
query: Record<string, string>;
|
|
15
|
+
/** Request headers */
|
|
16
|
+
headers: Record<string, string>;
|
|
17
|
+
/** Action name (for POST /actions/:action) */
|
|
18
|
+
action?: string;
|
|
19
|
+
/** Action payload */
|
|
20
|
+
payload?: Record<string, any>;
|
|
21
|
+
/** Attached user/session data (set by auth middleware) */
|
|
22
|
+
user?: { id: string; [key: string]: unknown };
|
|
23
|
+
/** Response status code (middleware can set this) */
|
|
24
|
+
status: number;
|
|
25
|
+
/** Response headers to add */
|
|
26
|
+
responseHeaders: Record<string, string>;
|
|
27
|
+
/** If set, short-circuit with this response body */
|
|
28
|
+
body?: string;
|
|
29
|
+
/** Arbitrary storage for middleware to share data */
|
|
30
|
+
state: Record<string, unknown>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── Middleware types ─────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export type NextFn = () => void | Promise<void>;
|
|
36
|
+
|
|
37
|
+
export type MiddlewareFn = (
|
|
38
|
+
ctx: MagneticContext,
|
|
39
|
+
next: NextFn,
|
|
40
|
+
) => void | Promise<void>;
|
|
41
|
+
|
|
42
|
+
// ── Middleware chain ─────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
export interface MiddlewareStack {
|
|
45
|
+
/** Add a middleware function */
|
|
46
|
+
use(fn: MiddlewareFn): void;
|
|
47
|
+
/** Run the middleware chain for a given context */
|
|
48
|
+
run(ctx: MagneticContext): Promise<MagneticContext>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Creates a middleware stack.
|
|
53
|
+
*
|
|
54
|
+
* Usage:
|
|
55
|
+
* ```ts
|
|
56
|
+
* const mw = createMiddleware();
|
|
57
|
+
* mw.use(logger);
|
|
58
|
+
* mw.use(cors);
|
|
59
|
+
* mw.use(auth);
|
|
60
|
+
* const ctx = await mw.run(context);
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export function createMiddleware(): MiddlewareStack {
|
|
64
|
+
const fns: MiddlewareFn[] = [];
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
use(fn: MiddlewareFn) {
|
|
68
|
+
fns.push(fn);
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
async run(ctx: MagneticContext): Promise<MagneticContext> {
|
|
72
|
+
let index = 0;
|
|
73
|
+
|
|
74
|
+
async function next(): Promise<void> {
|
|
75
|
+
if (index >= fns.length) return;
|
|
76
|
+
// Short-circuit if body was set (middleware wants to respond early)
|
|
77
|
+
if (ctx.body != null) return;
|
|
78
|
+
const fn = fns[index++];
|
|
79
|
+
await fn(ctx, next);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
await next();
|
|
83
|
+
return ctx;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Helper: create context from raw request ─────────────────────────
|
|
89
|
+
|
|
90
|
+
export function createContext(opts: {
|
|
91
|
+
method: string;
|
|
92
|
+
url: string;
|
|
93
|
+
headers?: Record<string, string>;
|
|
94
|
+
action?: string;
|
|
95
|
+
payload?: Record<string, any>;
|
|
96
|
+
}): MagneticContext {
|
|
97
|
+
const [path, qs] = (opts.url || '/').split('?');
|
|
98
|
+
const query: Record<string, string> = {};
|
|
99
|
+
if (qs) {
|
|
100
|
+
for (const pair of qs.split('&')) {
|
|
101
|
+
const [k, v] = pair.split('=');
|
|
102
|
+
if (k) query[decodeURIComponent(k)] = decodeURIComponent(v || '');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
method: opts.method,
|
|
108
|
+
path,
|
|
109
|
+
query,
|
|
110
|
+
headers: opts.headers || {},
|
|
111
|
+
action: opts.action,
|
|
112
|
+
payload: opts.payload,
|
|
113
|
+
status: 200,
|
|
114
|
+
responseHeaders: {},
|
|
115
|
+
state: {},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Built-in middleware ──────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
/** Logs request method + path + timing */
|
|
122
|
+
export const loggerMiddleware: MiddlewareFn = async (ctx, next) => {
|
|
123
|
+
const start = Date.now();
|
|
124
|
+
await next();
|
|
125
|
+
const ms = Date.now() - start;
|
|
126
|
+
console.log(`[magnetic] ${ctx.method} ${ctx.path} → ${ctx.status} (${ms}ms)`);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** CORS headers */
|
|
130
|
+
export function corsMiddleware(origins: string | string[] = '*'): MiddlewareFn {
|
|
131
|
+
const origin = Array.isArray(origins) ? origins.join(', ') : origins;
|
|
132
|
+
return async (ctx, next) => {
|
|
133
|
+
ctx.responseHeaders['Access-Control-Allow-Origin'] = origin;
|
|
134
|
+
ctx.responseHeaders['Access-Control-Allow-Headers'] = 'Content-Type';
|
|
135
|
+
ctx.responseHeaders['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS';
|
|
136
|
+
if (ctx.method === 'OPTIONS') {
|
|
137
|
+
ctx.status = 204;
|
|
138
|
+
ctx.body = '';
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
await next();
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Rate limiter (per-IP, sliding window) */
|
|
146
|
+
export function rateLimitMiddleware(opts: {
|
|
147
|
+
windowMs?: number;
|
|
148
|
+
max?: number;
|
|
149
|
+
} = {}): MiddlewareFn {
|
|
150
|
+
const windowMs = opts.windowMs || 60_000;
|
|
151
|
+
const max = opts.max || 100;
|
|
152
|
+
const hits = new Map<string, { count: number; resetAt: number }>();
|
|
153
|
+
|
|
154
|
+
return async (ctx, next) => {
|
|
155
|
+
const ip = ctx.headers['x-forwarded-for'] || ctx.headers['x-real-ip'] || 'unknown';
|
|
156
|
+
const now = Date.now();
|
|
157
|
+
let entry = hits.get(ip);
|
|
158
|
+
|
|
159
|
+
if (!entry || now > entry.resetAt) {
|
|
160
|
+
entry = { count: 0, resetAt: now + windowMs };
|
|
161
|
+
hits.set(ip, entry);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
entry.count++;
|
|
165
|
+
|
|
166
|
+
if (entry.count > max) {
|
|
167
|
+
ctx.status = 429;
|
|
168
|
+
ctx.body = JSON.stringify({ error: 'Too many requests' });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
await next();
|
|
173
|
+
};
|
|
174
|
+
}
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// @magnetic/server — Router
|
|
2
|
+
// Nested routes, dynamic params, layouts, guards, redirects, file-based conventions
|
|
3
|
+
|
|
4
|
+
import type { DomNode } from './jsx-runtime.ts';
|
|
5
|
+
|
|
6
|
+
// ── Types ───────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export type PageComponent = (props: {
|
|
9
|
+
params: Record<string, string>;
|
|
10
|
+
children?: DomNode;
|
|
11
|
+
[key: string]: unknown;
|
|
12
|
+
}) => DomNode;
|
|
13
|
+
|
|
14
|
+
export type LayoutComponent = (props: {
|
|
15
|
+
children: DomNode;
|
|
16
|
+
params: Record<string, string>;
|
|
17
|
+
path: string;
|
|
18
|
+
}) => DomNode;
|
|
19
|
+
|
|
20
|
+
/** Return true to allow, a string to redirect, or { redirect } */
|
|
21
|
+
export type RouteGuard = (ctx: {
|
|
22
|
+
path: string;
|
|
23
|
+
params: Record<string, string>;
|
|
24
|
+
}) => true | string | { redirect: string };
|
|
25
|
+
|
|
26
|
+
export interface RouteDefinition {
|
|
27
|
+
/** Path pattern: "/tasks/:id", "/about", "*" */
|
|
28
|
+
path: string;
|
|
29
|
+
/** Page component (leaf) */
|
|
30
|
+
page?: PageComponent;
|
|
31
|
+
/** Layout wrapping this route and its children */
|
|
32
|
+
layout?: LayoutComponent;
|
|
33
|
+
/** Guard — runs before render. Return true to allow, string to redirect */
|
|
34
|
+
guard?: RouteGuard;
|
|
35
|
+
/** Redirect target — if set, this route always redirects */
|
|
36
|
+
redirect?: string;
|
|
37
|
+
/** Nested child routes */
|
|
38
|
+
children?: RouteDefinition[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RouteMatch {
|
|
42
|
+
/** Matched page component */
|
|
43
|
+
page: PageComponent;
|
|
44
|
+
/** Extracted URL params */
|
|
45
|
+
params: Record<string, string>;
|
|
46
|
+
/** Layout chain from outermost to innermost */
|
|
47
|
+
layouts: LayoutComponent[];
|
|
48
|
+
/** Guards to run in order (outermost first) */
|
|
49
|
+
guards: RouteGuard[];
|
|
50
|
+
/** If set, this route should redirect instead of render */
|
|
51
|
+
redirect?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Result of resolving a route — either a DomNode or a redirect */
|
|
55
|
+
export type RouteResult =
|
|
56
|
+
| { kind: 'render'; dom: DomNode }
|
|
57
|
+
| { kind: 'redirect'; to: string };
|
|
58
|
+
|
|
59
|
+
// ── Compiled route node ─────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
interface CompiledNode {
|
|
62
|
+
def: RouteDefinition;
|
|
63
|
+
regex: RegExp;
|
|
64
|
+
paramNames: string[];
|
|
65
|
+
children: CompiledNode[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function compileNode(def: RouteDefinition, prefix: string): CompiledNode {
|
|
69
|
+
let pattern: string;
|
|
70
|
+
const paramNames: string[] = [];
|
|
71
|
+
|
|
72
|
+
if (def.path === '*') {
|
|
73
|
+
pattern = '.*';
|
|
74
|
+
} else {
|
|
75
|
+
const parts = def.path.split('/').filter(Boolean);
|
|
76
|
+
const regParts = parts.map((p) => {
|
|
77
|
+
if (p.startsWith(':')) {
|
|
78
|
+
paramNames.push(p.slice(1));
|
|
79
|
+
return '([^/]+)';
|
|
80
|
+
}
|
|
81
|
+
return p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
82
|
+
});
|
|
83
|
+
pattern = regParts.length ? regParts.join('/') : '';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const fullPattern = prefix + (pattern ? '/' + pattern : '');
|
|
87
|
+
const hasChildren = def.children && def.children.length > 0;
|
|
88
|
+
|
|
89
|
+
// If has children, match prefix (allow more segments after)
|
|
90
|
+
// If leaf, match exactly
|
|
91
|
+
const regexStr = hasChildren
|
|
92
|
+
? `^${fullPattern || ''}(?:/|$)`
|
|
93
|
+
: `^${fullPattern || '/'}$`;
|
|
94
|
+
|
|
95
|
+
const children = (def.children || []).map((c) =>
|
|
96
|
+
compileNode(c, fullPattern)
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
def,
|
|
101
|
+
regex: new RegExp(regexStr),
|
|
102
|
+
paramNames,
|
|
103
|
+
children,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ── Router ──────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
export interface Router {
|
|
110
|
+
/** Match a URL path. Returns matched page + layout chain + guards */
|
|
111
|
+
match(path: string): RouteMatch | null;
|
|
112
|
+
/** Resolve: run guards, apply redirects, nest layouts → DomNode or redirect */
|
|
113
|
+
resolve(path: string, appProps?: Record<string, unknown>): RouteResult | null;
|
|
114
|
+
/** All top-level route definitions */
|
|
115
|
+
routes: RouteDefinition[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Creates a router from (potentially nested) route definitions.
|
|
120
|
+
*
|
|
121
|
+
* Supports:
|
|
122
|
+
* - Static paths: `/about`
|
|
123
|
+
* - Dynamic params: `/tasks/:id`
|
|
124
|
+
* - Wildcard: `*`
|
|
125
|
+
* - Nested routes with `children`
|
|
126
|
+
* - Layouts at any level
|
|
127
|
+
* - Guards at any level (run outermost first)
|
|
128
|
+
* - Redirects (route-level or guard-returned)
|
|
129
|
+
* - First match wins (definition order)
|
|
130
|
+
*/
|
|
131
|
+
export function createRouter(routes: RouteDefinition[]): Router {
|
|
132
|
+
// Compile the root as a virtual node with empty path
|
|
133
|
+
const compiled: CompiledNode[] = routes.map((r) => compileNode(r, ''));
|
|
134
|
+
|
|
135
|
+
function matchPath(
|
|
136
|
+
nodes: CompiledNode[],
|
|
137
|
+
path: string,
|
|
138
|
+
params: Record<string, string>,
|
|
139
|
+
layouts: LayoutComponent[],
|
|
140
|
+
guards: RouteGuard[],
|
|
141
|
+
): RouteMatch | null {
|
|
142
|
+
const normalized = path === '/' ? '/' : path.replace(/\/+$/, '');
|
|
143
|
+
|
|
144
|
+
for (const node of nodes) {
|
|
145
|
+
const m = normalized.match(node.regex);
|
|
146
|
+
if (!m) continue;
|
|
147
|
+
|
|
148
|
+
// Extract params from this level
|
|
149
|
+
const levelParams = { ...params };
|
|
150
|
+
for (let i = 0; i < node.paramNames.length; i++) {
|
|
151
|
+
levelParams[node.paramNames[i]] = decodeURIComponent(m[i + 1]);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Collect layout + guard from this level
|
|
155
|
+
const levelLayouts = node.def.layout ? [...layouts, node.def.layout] : [...layouts];
|
|
156
|
+
const levelGuards = node.def.guard ? [...guards, node.def.guard] : [...guards];
|
|
157
|
+
|
|
158
|
+
// Redirect at route level
|
|
159
|
+
if (node.def.redirect) {
|
|
160
|
+
return {
|
|
161
|
+
page: () => ({ tag: 'div' }),
|
|
162
|
+
params: levelParams,
|
|
163
|
+
layouts: levelLayouts,
|
|
164
|
+
guards: levelGuards,
|
|
165
|
+
redirect: node.def.redirect,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Try children first (depth-first)
|
|
170
|
+
if (node.children.length > 0) {
|
|
171
|
+
const childMatch = matchPath(
|
|
172
|
+
node.children, normalized, levelParams, levelLayouts, levelGuards,
|
|
173
|
+
);
|
|
174
|
+
if (childMatch) return childMatch;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Leaf match — must have a page component
|
|
178
|
+
if (node.def.page) {
|
|
179
|
+
return {
|
|
180
|
+
page: node.def.page,
|
|
181
|
+
params: levelParams,
|
|
182
|
+
layouts: levelLayouts,
|
|
183
|
+
guards: levelGuards,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
routes,
|
|
192
|
+
|
|
193
|
+
match(path: string): RouteMatch | null {
|
|
194
|
+
return matchPath(compiled, path, {}, [], []);
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
resolve(path: string, appProps?: Record<string, unknown>): RouteResult | null {
|
|
198
|
+
const match = this.match(path);
|
|
199
|
+
if (!match) return null;
|
|
200
|
+
|
|
201
|
+
// Run guards in order
|
|
202
|
+
for (const guard of match.guards) {
|
|
203
|
+
const result = guard({ path, params: match.params });
|
|
204
|
+
if (result === true) continue;
|
|
205
|
+
const to = typeof result === 'string' ? result : result.redirect;
|
|
206
|
+
return { kind: 'redirect', to };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Route-level redirect
|
|
210
|
+
if (match.redirect) {
|
|
211
|
+
return { kind: 'redirect', to: match.redirect };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Render page
|
|
215
|
+
const pageProps = { params: match.params, ...appProps };
|
|
216
|
+
let dom = match.page(pageProps);
|
|
217
|
+
|
|
218
|
+
// Wrap in layouts (innermost first → outermost wraps)
|
|
219
|
+
for (let i = match.layouts.length - 1; i >= 0; i--) {
|
|
220
|
+
dom = match.layouts[i]({ children: dom, params: match.params, path });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return { kind: 'render', dom };
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── Convenience: renderRoute (backward compat) ─────────────────────
|
|
229
|
+
|
|
230
|
+
export function renderRoute(
|
|
231
|
+
router: Router,
|
|
232
|
+
path: string,
|
|
233
|
+
appProps?: Record<string, unknown>,
|
|
234
|
+
): DomNode | null {
|
|
235
|
+
const result = router.resolve(path, appProps);
|
|
236
|
+
if (!result) return null;
|
|
237
|
+
if (result.kind === 'redirect') {
|
|
238
|
+
// For backward compat, follow one redirect level
|
|
239
|
+
const r2 = router.resolve(result.to, appProps);
|
|
240
|
+
if (r2 && r2.kind === 'render') return r2.dom;
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
return result.dom;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Navigate action string ──────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
export function navigateAction(path: string): string {
|
|
249
|
+
return `navigate:${path}`;
|
|
250
|
+
}
|