@voltx/server 0.4.5 → 0.4.7
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 +113 -32
- package/dist/index.cjs +349 -28
- package/dist/index.d.cts +20 -8
- package/dist/index.d.ts +20 -8
- package/dist/index.js +348 -28
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<p align="center">
|
|
2
2
|
<strong>@voltx/server</strong><br/>
|
|
3
|
-
<em>Hono-based HTTP server with file-based routing, SSR, and
|
|
3
|
+
<em>Hono-based HTTP server with file-based routing, React SSR, and Vite plugins</em>
|
|
4
4
|
</p>
|
|
5
5
|
|
|
6
6
|
<p align="center">
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
---
|
|
13
13
|
|
|
14
|
-
The HTTP layer of the [VoltX](https://github.com/codewithshail/voltx) framework. Built on [Hono](https://hono.dev) with file-based routing, React SSR (streaming), CORS, logging, error handling, and static file serving.
|
|
14
|
+
The HTTP layer of the [VoltX](https://github.com/codewithshail/voltx) framework. Built on [Hono](https://hono.dev) with file-based page routing, file-based API routing, React SSR (streaming), CORS, logging, error handling, and static file serving.
|
|
15
15
|
|
|
16
16
|
## Installation
|
|
17
17
|
|
|
@@ -22,24 +22,125 @@ npm install @voltx/server
|
|
|
22
22
|
## Quick Start
|
|
23
23
|
|
|
24
24
|
```ts
|
|
25
|
+
// server.ts
|
|
25
26
|
import { Hono } from "hono";
|
|
26
27
|
import { registerSSR } from "@voltx/server";
|
|
28
|
+
import { registerRoutes } from "voltx/api";
|
|
27
29
|
|
|
28
30
|
const app = new Hono();
|
|
29
31
|
|
|
30
|
-
// API routes
|
|
31
|
-
app
|
|
32
|
+
// Auto-discover and mount all API routes from api/ directory
|
|
33
|
+
registerRoutes(app);
|
|
32
34
|
|
|
33
35
|
// SSR — renders React on the server, hydrates on the client
|
|
34
36
|
registerSSR(app, null, {
|
|
35
37
|
title: "My App",
|
|
36
38
|
entryServer: "src/entry-server.tsx",
|
|
37
39
|
entryClient: "src/entry-client.tsx",
|
|
40
|
+
css: "src/globals.css",
|
|
38
41
|
});
|
|
39
42
|
|
|
40
43
|
export default app;
|
|
41
44
|
```
|
|
42
45
|
|
|
46
|
+
```ts
|
|
47
|
+
// vite.config.ts
|
|
48
|
+
import { defineConfig } from "vite";
|
|
49
|
+
import devServer from "@hono/vite-dev-server";
|
|
50
|
+
import react from "@vitejs/plugin-react";
|
|
51
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
52
|
+
import { voltxRouter, voltxAPI } from "@voltx/server";
|
|
53
|
+
|
|
54
|
+
export default defineConfig({
|
|
55
|
+
plugins: [
|
|
56
|
+
react(),
|
|
57
|
+
tailwindcss(),
|
|
58
|
+
voltxRouter(), // File-based page routing → voltx/router
|
|
59
|
+
voltxAPI(), // File-based API routing → voltx/api
|
|
60
|
+
devServer({ entry: "server.ts" }),
|
|
61
|
+
],
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Vite Plugins
|
|
66
|
+
|
|
67
|
+
### `voltxRouter()` — File-Based Page Routing
|
|
68
|
+
|
|
69
|
+
Auto-discovers React components in `src/pages/` and provides the `voltx/router` virtual module.
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
src/pages/index.tsx → /
|
|
73
|
+
src/pages/about.tsx → /about
|
|
74
|
+
src/pages/blog/index.tsx → /blog
|
|
75
|
+
src/pages/blog/[slug].tsx → /blog/:slug (dynamic route)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
```tsx
|
|
79
|
+
// Use in entry-client.tsx and entry-server.tsx
|
|
80
|
+
import { VoltxRoutes, Link, useNavigate, useParams } from "voltx/router";
|
|
81
|
+
|
|
82
|
+
// VoltxRoutes renders the matched page component
|
|
83
|
+
// Link, NavLink, useNavigate, useParams, useLocation, useSearchParams
|
|
84
|
+
// are re-exported from react-router — no separate install needed
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
#### Options
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
voltxRouter({
|
|
91
|
+
pagesDir: "src/pages", // default: "src/pages"
|
|
92
|
+
})
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### `voltxAPI()` — File-Based API Routing
|
|
96
|
+
|
|
97
|
+
Auto-discovers API route files in `api/` and provides the `voltx/api` virtual module.
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
api/index.ts → /api
|
|
101
|
+
api/users.ts → /api/users
|
|
102
|
+
api/users/[id].ts → /api/users/:id
|
|
103
|
+
api/[...slug].ts → /api/* (catch-all)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Each file exports named HTTP method handlers:
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
// api/users.ts
|
|
110
|
+
import type { Context } from "@voltx/server";
|
|
111
|
+
|
|
112
|
+
export function GET(c: Context) {
|
|
113
|
+
return c.json([{ id: 1, name: "Alice" }]);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function POST(c: Context) {
|
|
117
|
+
const body = await c.req.json();
|
|
118
|
+
return c.json({ created: true }, 201);
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Supported methods: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`.
|
|
123
|
+
|
|
124
|
+
Routes are sorted automatically: static first, dynamic (`:param`) second, catch-all (`*`) last. HMR support means new files are picked up instantly in dev mode.
|
|
125
|
+
|
|
126
|
+
#### Options
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
voltxAPI({
|
|
130
|
+
apiDir: "api", // default: "api"
|
|
131
|
+
})
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
#### Usage in server.ts
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
import { registerRoutes } from "voltx/api";
|
|
138
|
+
|
|
139
|
+
// Mounts all discovered API handlers on the Hono app
|
|
140
|
+
const registered = registerRoutes(app);
|
|
141
|
+
// → [{ method: "GET", path: "/api" }, { method: "POST", path: "/api/users" }, ...]
|
|
142
|
+
```
|
|
143
|
+
|
|
43
144
|
## Server-Side Rendering
|
|
44
145
|
|
|
45
146
|
`registerSSR()` provides streaming React SSR with zero config:
|
|
@@ -47,6 +148,7 @@ export default app;
|
|
|
47
148
|
- **Dev mode** — works with `@hono/vite-dev-server` for HMR
|
|
48
149
|
- **Production** — reads the Vite client manifest for hashed asset paths, serves pre-built SSR bundle
|
|
49
150
|
- **Streaming** — uses `renderToReadableStream` for fast TTFB
|
|
151
|
+
- **CSS injection** — injects your global CSS in dev mode to prevent FOUC
|
|
50
152
|
- **Public env** — injects `window.__VOLTX_ENV__` for `VITE_*` variables
|
|
51
153
|
|
|
52
154
|
```ts
|
|
@@ -56,6 +158,7 @@ registerSSR(app, viteInstance, {
|
|
|
56
158
|
title: "My App",
|
|
57
159
|
entryServer: "src/entry-server.tsx",
|
|
58
160
|
entryClient: "src/entry-client.tsx",
|
|
161
|
+
css: "src/globals.css",
|
|
59
162
|
});
|
|
60
163
|
```
|
|
61
164
|
|
|
@@ -64,38 +167,16 @@ registerSSR(app, viteInstance, {
|
|
|
64
167
|
| `title` | `string` | HTML `<title>` |
|
|
65
168
|
| `entryServer` | `string` | Path to SSR entry (exports `render()`) |
|
|
66
169
|
| `entryClient` | `string` | Path to client hydration entry |
|
|
67
|
-
|
|
68
|
-
## File-Based Routing
|
|
69
|
-
|
|
70
|
-
Drop files in `api/` and they become API endpoints:
|
|
71
|
-
|
|
72
|
-
```
|
|
73
|
-
api/index.ts → GET /api
|
|
74
|
-
api/chat.ts → POST /api/chat
|
|
75
|
-
api/users/[id].ts → GET /api/users/:id
|
|
76
|
-
api/rag/query.ts → POST /api/rag/query
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
Each file exports HTTP method handlers:
|
|
80
|
-
|
|
81
|
-
```ts
|
|
82
|
-
import type { Context } from "@voltx/server";
|
|
83
|
-
|
|
84
|
-
export async function POST(c: Context) {
|
|
85
|
-
const body = await c.req.json();
|
|
86
|
-
return c.json({ message: "Hello!" });
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export function GET(c: Context) {
|
|
90
|
-
return c.json({ status: "ok" });
|
|
91
|
-
}
|
|
92
|
-
```
|
|
170
|
+
| `css` | `string` | Path to global CSS file (prevents FOUC in dev) |
|
|
93
171
|
|
|
94
172
|
## Features
|
|
95
173
|
|
|
174
|
+
- **Vite plugins** — `voltxRouter()` and `voltxAPI()` for Next.js-style file-based routing
|
|
175
|
+
- **Virtual modules** — `voltx/router` and `voltx/api` for clean imports
|
|
96
176
|
- **React SSR** — streaming server-side rendering with `registerSSR()`
|
|
97
|
-
- **
|
|
98
|
-
- **Dynamic routes** — `[param]` and `[...slug]` catch-all
|
|
177
|
+
- **Navigation** — `Link`, `NavLink`, `useNavigate`, `useParams` from `voltx/router`
|
|
178
|
+
- **Dynamic routes** — `[param]` and `[...slug]` catch-all for both pages and API
|
|
179
|
+
- **HMR** — new pages and API files are picked up instantly in dev mode
|
|
99
180
|
- **Built-in middleware** — CORS, request logging, error handling
|
|
100
181
|
- **Static file serving** — `public/` directory (favicon, robots.txt, manifest)
|
|
101
182
|
- **Full Hono access** — use any Hono middleware or plugin
|
package/dist/index.cjs
CHANGED
|
@@ -41,6 +41,7 @@ __export(index_exports, {
|
|
|
41
41
|
registerSSR: () => registerSSR,
|
|
42
42
|
registerStaticFiles: () => registerStaticFiles,
|
|
43
43
|
scanAndRegisterRoutes: () => scanAndRegisterRoutes,
|
|
44
|
+
voltxAPI: () => voltxAPI,
|
|
44
45
|
voltxRouter: () => voltxRouter
|
|
45
46
|
});
|
|
46
47
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -333,55 +334,374 @@ function voltxRouter(options = {}) {
|
|
|
333
334
|
},
|
|
334
335
|
load(id) {
|
|
335
336
|
if (id === RESOLVED_ID) {
|
|
336
|
-
return
|
|
337
|
-
|
|
338
|
-
|
|
337
|
+
return generateRouterModule(pagesDir);
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
handleHotUpdate({ file, server }) {
|
|
341
|
+
if (file.includes(pagesDir.replace(/\//g, "/"))) {
|
|
342
|
+
const mod = server.moduleGraph.getModuleById(RESOLVED_ID);
|
|
343
|
+
if (mod) {
|
|
344
|
+
server.moduleGraph.invalidateModule(mod);
|
|
345
|
+
server.ws.send({ type: "full-reload" });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
function generateRouterModule(pagesDir) {
|
|
352
|
+
return `
|
|
353
|
+
import { createElement, Component, Suspense } from "react";
|
|
354
|
+
import { Routes, Route, Outlet } from "react-router";
|
|
355
|
+
|
|
356
|
+
// \u2500\u2500\u2500 Glob all files in pages directory \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
357
|
+
const allFiles = import.meta.glob("/${pagesDir}/**/*.tsx", { eager: true });
|
|
339
358
|
|
|
340
|
-
|
|
359
|
+
// \u2500\u2500\u2500 Classify files by type \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
360
|
+
// Special files: layout.tsx, loading.tsx, error.tsx, not-found.tsx
|
|
361
|
+
// Page files: everything else (index.tsx, about.tsx, [slug].tsx, etc.)
|
|
341
362
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
363
|
+
const SPECIAL_FILES = new Set(["layout", "loading", "error", "not-found"]);
|
|
364
|
+
|
|
365
|
+
function classifyFiles() {
|
|
366
|
+
const pages = {}; // dir \u2192 [{ routePath, Component }]
|
|
367
|
+
const layouts = {}; // dir \u2192 Component
|
|
368
|
+
const loadings = {}; // dir \u2192 Component
|
|
369
|
+
const errors = {}; // dir \u2192 Component
|
|
370
|
+
let notFound = null; // global not-found component
|
|
371
|
+
|
|
372
|
+
for (const [filePath, mod] of Object.entries(allFiles)) {
|
|
345
373
|
const Component = mod.default;
|
|
346
374
|
if (!Component) continue;
|
|
347
375
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
376
|
+
// Get relative path from pages dir: "/src/pages/blog/index.tsx" \u2192 "blog/index.tsx"
|
|
377
|
+
const rel = filePath.replace("/${pagesDir}/", "");
|
|
378
|
+
const parts = rel.replace(/\\.tsx$/, "").split("/");
|
|
379
|
+
const fileName = parts[parts.length - 1];
|
|
380
|
+
const dir = parts.length > 1 ? parts.slice(0, -1).join("/") : "";
|
|
381
|
+
|
|
382
|
+
if (fileName === "layout") {
|
|
383
|
+
layouts[dir] = Component;
|
|
384
|
+
} else if (fileName === "loading") {
|
|
385
|
+
loadings[dir] = Component;
|
|
386
|
+
} else if (fileName === "error") {
|
|
387
|
+
errors[dir] = Component;
|
|
388
|
+
} else if (fileName === "not-found") {
|
|
389
|
+
if (dir === "") notFound = Component;
|
|
390
|
+
} else {
|
|
391
|
+
// It's a page file \u2014 compute route path
|
|
392
|
+
let routePath = rel
|
|
393
|
+
.replace(/\\.tsx$/, "")
|
|
394
|
+
.replace(/(^|\\/)index$/, "$1")
|
|
395
|
+
.replace(/\\[([^\\]]+)\\]/g, ":$1")
|
|
396
|
+
.replace(/\\/$/, "");
|
|
397
|
+
|
|
398
|
+
if (!routePath) routePath = "";
|
|
353
399
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
routePath = routePath.slice(0, -1);
|
|
400
|
+
if (!pages[dir]) pages[dir] = [];
|
|
401
|
+
pages[dir].push({ routePath: "/" + routePath, Component });
|
|
357
402
|
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return { pages, layouts, loadings, errors, notFound };
|
|
406
|
+
}
|
|
358
407
|
|
|
359
|
-
|
|
408
|
+
const { pages, layouts, loadings, errors, notFound } = classifyFiles();
|
|
409
|
+
|
|
410
|
+
// \u2500\u2500\u2500 Error Boundary Component \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
411
|
+
class VoltxErrorBoundary extends Component {
|
|
412
|
+
constructor(props) {
|
|
413
|
+
super(props);
|
|
414
|
+
this.state = { hasError: false, error: null };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
static getDerivedStateFromError(error) {
|
|
418
|
+
return { hasError: true, error };
|
|
360
419
|
}
|
|
361
|
-
|
|
420
|
+
|
|
421
|
+
render() {
|
|
422
|
+
if (this.state.hasError) {
|
|
423
|
+
const reset = () => this.setState({ hasError: false, error: null });
|
|
424
|
+
if (this.props.fallback) {
|
|
425
|
+
return createElement(this.props.fallback, {
|
|
426
|
+
error: this.state.error,
|
|
427
|
+
reset,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
return createElement("div", { style: { padding: "2rem" } },
|
|
431
|
+
createElement("h2", null, "Something went wrong"),
|
|
432
|
+
createElement("button", { onClick: reset }, "Try again")
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
return this.props.children;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// \u2500\u2500\u2500 Build nested route tree \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
440
|
+
// Directories are sorted so parents come before children.
|
|
441
|
+
// Each directory with a layout.tsx becomes a layout route.
|
|
442
|
+
// Pages within that directory become its children.
|
|
443
|
+
|
|
444
|
+
function getAllDirs() {
|
|
445
|
+
const dirs = new Set([""]);
|
|
446
|
+
for (const dir of Object.keys(pages)) dirs.add(dir);
|
|
447
|
+
for (const dir of Object.keys(layouts)) dirs.add(dir);
|
|
448
|
+
for (const dir of Object.keys(loadings)) dirs.add(dir);
|
|
449
|
+
for (const dir of Object.keys(errors)) dirs.add(dir);
|
|
450
|
+
return Array.from(dirs).sort((a, b) => {
|
|
451
|
+
if (a === "") return -1;
|
|
452
|
+
if (b === "") return 1;
|
|
453
|
+
return a.localeCompare(b);
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function getParentDir(dir) {
|
|
458
|
+
if (dir === "") return null;
|
|
459
|
+
const idx = dir.lastIndexOf("/");
|
|
460
|
+
return idx === -1 ? "" : dir.substring(0, idx);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Find the nearest ancestor directory that has a layout
|
|
464
|
+
function findLayoutAncestor(dir) {
|
|
465
|
+
let current = getParentDir(dir);
|
|
466
|
+
while (current !== null) {
|
|
467
|
+
if (layouts[current]) return current;
|
|
468
|
+
current = getParentDir(current);
|
|
469
|
+
}
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Wrap element with loading (Suspense) and error boundary if available for a dir
|
|
474
|
+
function wrapElement(element, dir) {
|
|
475
|
+
let wrapped = element;
|
|
476
|
+
if (errors[dir]) {
|
|
477
|
+
wrapped = createElement(VoltxErrorBoundary, { fallback: errors[dir] }, wrapped);
|
|
478
|
+
}
|
|
479
|
+
if (loadings[dir]) {
|
|
480
|
+
wrapped = createElement(Suspense, { fallback: createElement(loadings[dir]) }, wrapped);
|
|
481
|
+
}
|
|
482
|
+
return wrapped;
|
|
362
483
|
}
|
|
363
484
|
|
|
364
|
-
|
|
485
|
+
// \u2500\u2500\u2500 Layout wrapper component \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
486
|
+
// A layout component that renders the layout with <Outlet /> for children
|
|
487
|
+
function createLayoutElement(LayoutComp, dir) {
|
|
488
|
+
// The layout component receives <Outlet /> as its children rendering point
|
|
489
|
+
// We create a wrapper that renders Layout with Outlet inside
|
|
490
|
+
function LayoutWrapper() {
|
|
491
|
+
return createElement(LayoutComp, null, createElement(Outlet));
|
|
492
|
+
}
|
|
493
|
+
LayoutWrapper.displayName = "Layout(" + (dir || "root") + ")";
|
|
494
|
+
return LayoutWrapper;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// \u2500\u2500\u2500 Generate the <Routes> tree \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
498
|
+
function buildRouteElements() {
|
|
499
|
+
const allDirs = getAllDirs();
|
|
500
|
+
|
|
501
|
+
// Build a tree structure: each dir can have child routes and child layout dirs
|
|
502
|
+
// dirChildren[dir] = array of Route elements (pages + nested layout routes)
|
|
503
|
+
const dirChildren = {};
|
|
504
|
+
for (const dir of allDirs) {
|
|
505
|
+
dirChildren[dir] = [];
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// First, add page routes to their directory
|
|
509
|
+
for (const dir of allDirs) {
|
|
510
|
+
const dirPages = pages[dir] || [];
|
|
511
|
+
for (const { routePath, Component: PageComp } of dirPages) {
|
|
512
|
+
// Compute the path relative to the directory's base
|
|
513
|
+
const dirBase = dir ? "/" + dir : "";
|
|
514
|
+
let relativePath = routePath;
|
|
515
|
+
if (dirBase && relativePath.startsWith(dirBase)) {
|
|
516
|
+
relativePath = relativePath.substring(dirBase.length);
|
|
517
|
+
}
|
|
518
|
+
if (relativePath.startsWith("/")) relativePath = relativePath.substring(1);
|
|
519
|
+
|
|
520
|
+
const isIndex = relativePath === "";
|
|
521
|
+
const routeProps = isIndex
|
|
522
|
+
? { index: true, element: createElement(PageComp), key: routePath }
|
|
523
|
+
: { path: relativePath, element: createElement(PageComp), key: routePath };
|
|
524
|
+
|
|
525
|
+
dirChildren[dir].push(createElement(Route, routeProps));
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Now, nest directories bottom-up: child dirs become Route children of parent dirs
|
|
530
|
+
// Process in reverse order (deepest first)
|
|
531
|
+
const reversedDirs = allDirs.slice().reverse();
|
|
532
|
+
|
|
533
|
+
for (const dir of reversedDirs) {
|
|
534
|
+
if (dir === "") continue; // root handled last
|
|
535
|
+
|
|
536
|
+
const parentDir = getParentDir(dir);
|
|
537
|
+
if (parentDir === null) continue;
|
|
538
|
+
|
|
539
|
+
const hasLayout = !!layouts[dir];
|
|
540
|
+
const children = dirChildren[dir];
|
|
541
|
+
|
|
542
|
+
// Compute path segment for this directory relative to parent
|
|
543
|
+
const parentBase = parentDir ? parentDir + "/" : "";
|
|
544
|
+
const segment = dir.startsWith(parentBase) ? dir.substring(parentBase.length) : dir;
|
|
545
|
+
// Convert [param] to :param in segment
|
|
546
|
+
const routeSegment = segment.replace(/\\[([^\\]]+)\\]/g, ":$1");
|
|
547
|
+
|
|
548
|
+
if (hasLayout) {
|
|
549
|
+
// This dir has a layout \u2014 create a layout route with children
|
|
550
|
+
const LayoutWrapper = createLayoutElement(layouts[dir], dir);
|
|
551
|
+
let layoutElement = createElement(LayoutWrapper);
|
|
552
|
+
layoutElement = wrapElement(layoutElement, dir);
|
|
553
|
+
|
|
554
|
+
const layoutRoute = createElement(
|
|
555
|
+
Route,
|
|
556
|
+
{ path: routeSegment, element: layoutElement, key: "layout:" + dir },
|
|
557
|
+
...children
|
|
558
|
+
);
|
|
559
|
+
dirChildren[parentDir].push(layoutRoute);
|
|
560
|
+
} else if (children.length > 0) {
|
|
561
|
+
// No layout \u2014 just a path prefix grouping
|
|
562
|
+
if (routeSegment) {
|
|
563
|
+
const prefixRoute = createElement(
|
|
564
|
+
Route,
|
|
565
|
+
{ path: routeSegment, key: "prefix:" + dir },
|
|
566
|
+
...children
|
|
567
|
+
);
|
|
568
|
+
dirChildren[parentDir].push(prefixRoute);
|
|
569
|
+
} else {
|
|
570
|
+
// Empty segment \u2014 push children directly
|
|
571
|
+
dirChildren[parentDir].push(...children);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Build root routes
|
|
577
|
+
const rootChildren = dirChildren[""];
|
|
578
|
+
|
|
579
|
+
// Add not-found catch-all at the end
|
|
580
|
+
if (notFound) {
|
|
581
|
+
rootChildren.push(
|
|
582
|
+
createElement(Route, { path: "*", element: createElement(notFound), key: "not-found" })
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// If root has a layout, wrap everything in it
|
|
587
|
+
if (layouts[""]) {
|
|
588
|
+
const RootLayout = createLayoutElement(layouts[""], "");
|
|
589
|
+
let rootElement = createElement(RootLayout);
|
|
590
|
+
rootElement = wrapElement(rootElement, "");
|
|
591
|
+
|
|
592
|
+
return createElement(
|
|
593
|
+
Routes,
|
|
594
|
+
null,
|
|
595
|
+
createElement(Route, { path: "/", element: rootElement }, ...rootChildren)
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// No root layout \u2014 wrap with loading/error if present
|
|
600
|
+
let routesElement = createElement(Routes, null, ...rootChildren);
|
|
601
|
+
if (errors[""]) {
|
|
602
|
+
routesElement = createElement(VoltxErrorBoundary, { fallback: errors[""] }, routesElement);
|
|
603
|
+
}
|
|
604
|
+
if (loadings[""]) {
|
|
605
|
+
routesElement = createElement(Suspense, { fallback: createElement(loadings[""]) }, routesElement);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return routesElement;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// \u2500\u2500\u2500 Collect flat routes list (for backward compat) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
612
|
+
function collectRoutes() {
|
|
613
|
+
const result = [];
|
|
614
|
+
for (const dirPages of Object.values(pages)) {
|
|
615
|
+
for (const { routePath, Component } of dirPages) {
|
|
616
|
+
result.push({ path: routePath, Component });
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return result;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const routes = collectRoutes();
|
|
365
623
|
|
|
366
624
|
export function VoltxRoutes() {
|
|
367
|
-
return
|
|
368
|
-
Routes,
|
|
369
|
-
null,
|
|
370
|
-
routes.map(({ path, Component }) =>
|
|
371
|
-
createElement(Route, { key: path, path, element: createElement(Component) })
|
|
372
|
-
)
|
|
373
|
-
);
|
|
625
|
+
return buildRouteElements();
|
|
374
626
|
}
|
|
375
627
|
|
|
376
628
|
export { routes };
|
|
377
629
|
|
|
378
630
|
// Navigation primitives \u2014 single import source
|
|
379
|
-
export { Link, NavLink, useNavigate, useParams, useLocation, useSearchParams } from "react-router";
|
|
631
|
+
export { Link, NavLink, Outlet, useNavigate, useParams, useLocation, useSearchParams, useOutletContext } from "react-router";
|
|
632
|
+
`;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// src/vite-api-plugin.ts
|
|
636
|
+
function voltxAPI(options = {}) {
|
|
637
|
+
const apiDir = options.apiDir ?? "api";
|
|
638
|
+
const PUBLIC_ID = "voltx/api";
|
|
639
|
+
const RESOLVED_ID = "\0voltx/api";
|
|
640
|
+
return {
|
|
641
|
+
name: "voltx-api",
|
|
642
|
+
enforce: "pre",
|
|
643
|
+
resolveId(id) {
|
|
644
|
+
if (id === PUBLIC_ID) {
|
|
645
|
+
return RESOLVED_ID;
|
|
646
|
+
}
|
|
647
|
+
},
|
|
648
|
+
load(id) {
|
|
649
|
+
if (id === RESOLVED_ID) {
|
|
650
|
+
return `
|
|
651
|
+
const modules = import.meta.glob("/${apiDir}/**/*.ts", { eager: true });
|
|
652
|
+
|
|
653
|
+
const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"];
|
|
654
|
+
|
|
655
|
+
function fileToRoute(filePath) {
|
|
656
|
+
let route = filePath
|
|
657
|
+
.replace("/${apiDir}", "/${apiDir}")
|
|
658
|
+
.replace(/\\.ts$/, "")
|
|
659
|
+
.replace(/\\/index$/, "");
|
|
660
|
+
|
|
661
|
+
// Convert [param] -> :param
|
|
662
|
+
route = route.replace(/\\[([^\\]\\.]+)\\]/g, ":$1");
|
|
663
|
+
// Convert [...slug] -> *
|
|
664
|
+
route = route.replace(/\\[\\.\\.\\.([^\\]]+)\\]/g, "*");
|
|
665
|
+
|
|
666
|
+
if (!route || route === "/${apiDir}") route = "/${apiDir}";
|
|
667
|
+
return route;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export function registerRoutes(app) {
|
|
671
|
+
const registered = [];
|
|
672
|
+
|
|
673
|
+
// Sort: static routes first, dynamic (:param) second, catch-all (*) last
|
|
674
|
+
const entries = Object.entries(modules).sort(([a], [b]) => {
|
|
675
|
+
const ra = fileToRoute(a);
|
|
676
|
+
const rb = fileToRoute(b);
|
|
677
|
+
const sa = ra.includes("*") ? 2 : ra.includes(":") ? 1 : 0;
|
|
678
|
+
const sb = rb.includes("*") ? 2 : rb.includes(":") ? 1 : 0;
|
|
679
|
+
if (sa !== sb) return sa - sb;
|
|
680
|
+
return ra.localeCompare(rb);
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
for (const [filePath, mod] of entries) {
|
|
684
|
+
const route = fileToRoute(filePath);
|
|
685
|
+
|
|
686
|
+
for (const method of HTTP_METHODS) {
|
|
687
|
+
const handler = mod[method];
|
|
688
|
+
if (typeof handler === "function") {
|
|
689
|
+
app.on(method, route, handler);
|
|
690
|
+
registered.push({ method, path: route });
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
return registered;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
export { modules as apiModules };
|
|
380
699
|
`;
|
|
381
700
|
}
|
|
382
701
|
},
|
|
702
|
+
// HMR: when a file in api/ is added/removed, invalidate the virtual module
|
|
383
703
|
handleHotUpdate({ file, server }) {
|
|
384
|
-
if (file.includes(
|
|
704
|
+
if (file.includes(`/${apiDir}/`) || file.endsWith(`/${apiDir}`)) {
|
|
385
705
|
const mod = server.moduleGraph.getModuleById(RESOLVED_ID);
|
|
386
706
|
if (mod) {
|
|
387
707
|
server.moduleGraph.invalidateModule(mod);
|
|
@@ -532,7 +852,7 @@ ${cssLinks}
|
|
|
532
852
|
}
|
|
533
853
|
|
|
534
854
|
// src/index.ts
|
|
535
|
-
var VERSION = "0.4.
|
|
855
|
+
var VERSION = "0.4.7";
|
|
536
856
|
// Annotate the CommonJS export names for ESM import in node:
|
|
537
857
|
0 && (module.exports = {
|
|
538
858
|
Hono,
|
|
@@ -546,5 +866,6 @@ var VERSION = "0.4.5";
|
|
|
546
866
|
registerSSR,
|
|
547
867
|
registerStaticFiles,
|
|
548
868
|
scanAndRegisterRoutes,
|
|
869
|
+
voltxAPI,
|
|
549
870
|
voltxRouter
|
|
550
871
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -185,18 +185,30 @@ interface VoltxRouterOptions {
|
|
|
185
185
|
* VoltX file-based router plugin for Vite.
|
|
186
186
|
*
|
|
187
187
|
* Scans `src/pages/` and generates a virtual module that maps
|
|
188
|
-
* file paths to routes —
|
|
188
|
+
* file paths to routes with nested layout support — like Next.js App Router.
|
|
189
189
|
*
|
|
190
190
|
* Usage:
|
|
191
191
|
* import { Link, VoltxRoutes, useNavigate } from "voltx/router";
|
|
192
|
+
*/
|
|
193
|
+
declare function voltxRouter(options?: VoltxRouterOptions): Plugin;
|
|
194
|
+
|
|
195
|
+
interface VoltxAPIOptions {
|
|
196
|
+
/** Directory to scan for API route files (default: "api") */
|
|
197
|
+
apiDir?: string;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* VoltX file-based API routing plugin for Vite.
|
|
201
|
+
*
|
|
202
|
+
* Usage in server.ts:
|
|
203
|
+
* import { registerRoutes } from "voltx/api";
|
|
204
|
+
* registerRoutes(app);
|
|
192
205
|
*
|
|
193
206
|
* Convention:
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
* src/pages/blog/[slug].tsx → /blog/:slug
|
|
207
|
+
* api/index.ts → /api
|
|
208
|
+
* api/users.ts → /api/users
|
|
209
|
+
* api/users/[id].ts → /api/users/:id
|
|
198
210
|
*/
|
|
199
|
-
declare function
|
|
211
|
+
declare function voltxAPI(options?: VoltxAPIOptions): Plugin;
|
|
200
212
|
|
|
201
213
|
interface SSROptions {
|
|
202
214
|
/** Path to entry-server module (default: src/entry-server.tsx) */
|
|
@@ -245,6 +257,6 @@ interface ViteDevServer {
|
|
|
245
257
|
*/
|
|
246
258
|
declare function registerSSR(app: Hono, vite: ViteDevServer | null, options?: SSROptions): void;
|
|
247
259
|
|
|
248
|
-
declare const VERSION = "0.4.
|
|
260
|
+
declare const VERSION = "0.4.7";
|
|
249
261
|
|
|
250
|
-
export { type CorsConfig, type HttpMethod, type MiddlewareHandler, type RouteEntry, type RouteHandler, type RouteModule, type SSROptions, type ServerConfig, type ServerInfo, VERSION, type ViteDevOptions, type VoltxRouterOptions, type VoltxServer, createCorsMiddleware, createErrorHandler, createLoggerMiddleware, createServer, createViteDevConfig, filePathToUrlPath, registerSSR, registerStaticFiles, scanAndRegisterRoutes, voltxRouter };
|
|
262
|
+
export { type CorsConfig, type HttpMethod, type MiddlewareHandler, type RouteEntry, type RouteHandler, type RouteModule, type SSROptions, type ServerConfig, type ServerInfo, VERSION, type ViteDevOptions, type VoltxAPIOptions, type VoltxRouterOptions, type VoltxServer, createCorsMiddleware, createErrorHandler, createLoggerMiddleware, createServer, createViteDevConfig, filePathToUrlPath, registerSSR, registerStaticFiles, scanAndRegisterRoutes, voltxAPI, voltxRouter };
|
package/dist/index.d.ts
CHANGED
|
@@ -185,18 +185,30 @@ interface VoltxRouterOptions {
|
|
|
185
185
|
* VoltX file-based router plugin for Vite.
|
|
186
186
|
*
|
|
187
187
|
* Scans `src/pages/` and generates a virtual module that maps
|
|
188
|
-
* file paths to routes —
|
|
188
|
+
* file paths to routes with nested layout support — like Next.js App Router.
|
|
189
189
|
*
|
|
190
190
|
* Usage:
|
|
191
191
|
* import { Link, VoltxRoutes, useNavigate } from "voltx/router";
|
|
192
|
+
*/
|
|
193
|
+
declare function voltxRouter(options?: VoltxRouterOptions): Plugin;
|
|
194
|
+
|
|
195
|
+
interface VoltxAPIOptions {
|
|
196
|
+
/** Directory to scan for API route files (default: "api") */
|
|
197
|
+
apiDir?: string;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* VoltX file-based API routing plugin for Vite.
|
|
201
|
+
*
|
|
202
|
+
* Usage in server.ts:
|
|
203
|
+
* import { registerRoutes } from "voltx/api";
|
|
204
|
+
* registerRoutes(app);
|
|
192
205
|
*
|
|
193
206
|
* Convention:
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
* src/pages/blog/[slug].tsx → /blog/:slug
|
|
207
|
+
* api/index.ts → /api
|
|
208
|
+
* api/users.ts → /api/users
|
|
209
|
+
* api/users/[id].ts → /api/users/:id
|
|
198
210
|
*/
|
|
199
|
-
declare function
|
|
211
|
+
declare function voltxAPI(options?: VoltxAPIOptions): Plugin;
|
|
200
212
|
|
|
201
213
|
interface SSROptions {
|
|
202
214
|
/** Path to entry-server module (default: src/entry-server.tsx) */
|
|
@@ -245,6 +257,6 @@ interface ViteDevServer {
|
|
|
245
257
|
*/
|
|
246
258
|
declare function registerSSR(app: Hono, vite: ViteDevServer | null, options?: SSROptions): void;
|
|
247
259
|
|
|
248
|
-
declare const VERSION = "0.4.
|
|
260
|
+
declare const VERSION = "0.4.7";
|
|
249
261
|
|
|
250
|
-
export { type CorsConfig, type HttpMethod, type MiddlewareHandler, type RouteEntry, type RouteHandler, type RouteModule, type SSROptions, type ServerConfig, type ServerInfo, VERSION, type ViteDevOptions, type VoltxRouterOptions, type VoltxServer, createCorsMiddleware, createErrorHandler, createLoggerMiddleware, createServer, createViteDevConfig, filePathToUrlPath, registerSSR, registerStaticFiles, scanAndRegisterRoutes, voltxRouter };
|
|
262
|
+
export { type CorsConfig, type HttpMethod, type MiddlewareHandler, type RouteEntry, type RouteHandler, type RouteModule, type SSROptions, type ServerConfig, type ServerInfo, VERSION, type ViteDevOptions, type VoltxAPIOptions, type VoltxRouterOptions, type VoltxServer, createCorsMiddleware, createErrorHandler, createLoggerMiddleware, createServer, createViteDevConfig, filePathToUrlPath, registerSSR, registerStaticFiles, scanAndRegisterRoutes, voltxAPI, voltxRouter };
|
package/dist/index.js
CHANGED
|
@@ -286,55 +286,374 @@ function voltxRouter(options = {}) {
|
|
|
286
286
|
},
|
|
287
287
|
load(id) {
|
|
288
288
|
if (id === RESOLVED_ID) {
|
|
289
|
-
return
|
|
290
|
-
|
|
291
|
-
|
|
289
|
+
return generateRouterModule(pagesDir);
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
handleHotUpdate({ file, server }) {
|
|
293
|
+
if (file.includes(pagesDir.replace(/\//g, "/"))) {
|
|
294
|
+
const mod = server.moduleGraph.getModuleById(RESOLVED_ID);
|
|
295
|
+
if (mod) {
|
|
296
|
+
server.moduleGraph.invalidateModule(mod);
|
|
297
|
+
server.ws.send({ type: "full-reload" });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
function generateRouterModule(pagesDir) {
|
|
304
|
+
return `
|
|
305
|
+
import { createElement, Component, Suspense } from "react";
|
|
306
|
+
import { Routes, Route, Outlet } from "react-router";
|
|
307
|
+
|
|
308
|
+
// \u2500\u2500\u2500 Glob all files in pages directory \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
309
|
+
const allFiles = import.meta.glob("/${pagesDir}/**/*.tsx", { eager: true });
|
|
292
310
|
|
|
293
|
-
|
|
311
|
+
// \u2500\u2500\u2500 Classify files by type \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
312
|
+
// Special files: layout.tsx, loading.tsx, error.tsx, not-found.tsx
|
|
313
|
+
// Page files: everything else (index.tsx, about.tsx, [slug].tsx, etc.)
|
|
294
314
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
315
|
+
const SPECIAL_FILES = new Set(["layout", "loading", "error", "not-found"]);
|
|
316
|
+
|
|
317
|
+
function classifyFiles() {
|
|
318
|
+
const pages = {}; // dir \u2192 [{ routePath, Component }]
|
|
319
|
+
const layouts = {}; // dir \u2192 Component
|
|
320
|
+
const loadings = {}; // dir \u2192 Component
|
|
321
|
+
const errors = {}; // dir \u2192 Component
|
|
322
|
+
let notFound = null; // global not-found component
|
|
323
|
+
|
|
324
|
+
for (const [filePath, mod] of Object.entries(allFiles)) {
|
|
298
325
|
const Component = mod.default;
|
|
299
326
|
if (!Component) continue;
|
|
300
327
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
328
|
+
// Get relative path from pages dir: "/src/pages/blog/index.tsx" \u2192 "blog/index.tsx"
|
|
329
|
+
const rel = filePath.replace("/${pagesDir}/", "");
|
|
330
|
+
const parts = rel.replace(/\\.tsx$/, "").split("/");
|
|
331
|
+
const fileName = parts[parts.length - 1];
|
|
332
|
+
const dir = parts.length > 1 ? parts.slice(0, -1).join("/") : "";
|
|
333
|
+
|
|
334
|
+
if (fileName === "layout") {
|
|
335
|
+
layouts[dir] = Component;
|
|
336
|
+
} else if (fileName === "loading") {
|
|
337
|
+
loadings[dir] = Component;
|
|
338
|
+
} else if (fileName === "error") {
|
|
339
|
+
errors[dir] = Component;
|
|
340
|
+
} else if (fileName === "not-found") {
|
|
341
|
+
if (dir === "") notFound = Component;
|
|
342
|
+
} else {
|
|
343
|
+
// It's a page file \u2014 compute route path
|
|
344
|
+
let routePath = rel
|
|
345
|
+
.replace(/\\.tsx$/, "")
|
|
346
|
+
.replace(/(^|\\/)index$/, "$1")
|
|
347
|
+
.replace(/\\[([^\\]]+)\\]/g, ":$1")
|
|
348
|
+
.replace(/\\/$/, "");
|
|
349
|
+
|
|
350
|
+
if (!routePath) routePath = "";
|
|
306
351
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
routePath = routePath.slice(0, -1);
|
|
352
|
+
if (!pages[dir]) pages[dir] = [];
|
|
353
|
+
pages[dir].push({ routePath: "/" + routePath, Component });
|
|
310
354
|
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return { pages, layouts, loadings, errors, notFound };
|
|
358
|
+
}
|
|
311
359
|
|
|
312
|
-
|
|
360
|
+
const { pages, layouts, loadings, errors, notFound } = classifyFiles();
|
|
361
|
+
|
|
362
|
+
// \u2500\u2500\u2500 Error Boundary Component \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
363
|
+
class VoltxErrorBoundary extends Component {
|
|
364
|
+
constructor(props) {
|
|
365
|
+
super(props);
|
|
366
|
+
this.state = { hasError: false, error: null };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
static getDerivedStateFromError(error) {
|
|
370
|
+
return { hasError: true, error };
|
|
313
371
|
}
|
|
314
|
-
|
|
372
|
+
|
|
373
|
+
render() {
|
|
374
|
+
if (this.state.hasError) {
|
|
375
|
+
const reset = () => this.setState({ hasError: false, error: null });
|
|
376
|
+
if (this.props.fallback) {
|
|
377
|
+
return createElement(this.props.fallback, {
|
|
378
|
+
error: this.state.error,
|
|
379
|
+
reset,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
return createElement("div", { style: { padding: "2rem" } },
|
|
383
|
+
createElement("h2", null, "Something went wrong"),
|
|
384
|
+
createElement("button", { onClick: reset }, "Try again")
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
return this.props.children;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// \u2500\u2500\u2500 Build nested route tree \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
392
|
+
// Directories are sorted so parents come before children.
|
|
393
|
+
// Each directory with a layout.tsx becomes a layout route.
|
|
394
|
+
// Pages within that directory become its children.
|
|
395
|
+
|
|
396
|
+
function getAllDirs() {
|
|
397
|
+
const dirs = new Set([""]);
|
|
398
|
+
for (const dir of Object.keys(pages)) dirs.add(dir);
|
|
399
|
+
for (const dir of Object.keys(layouts)) dirs.add(dir);
|
|
400
|
+
for (const dir of Object.keys(loadings)) dirs.add(dir);
|
|
401
|
+
for (const dir of Object.keys(errors)) dirs.add(dir);
|
|
402
|
+
return Array.from(dirs).sort((a, b) => {
|
|
403
|
+
if (a === "") return -1;
|
|
404
|
+
if (b === "") return 1;
|
|
405
|
+
return a.localeCompare(b);
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function getParentDir(dir) {
|
|
410
|
+
if (dir === "") return null;
|
|
411
|
+
const idx = dir.lastIndexOf("/");
|
|
412
|
+
return idx === -1 ? "" : dir.substring(0, idx);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Find the nearest ancestor directory that has a layout
|
|
416
|
+
function findLayoutAncestor(dir) {
|
|
417
|
+
let current = getParentDir(dir);
|
|
418
|
+
while (current !== null) {
|
|
419
|
+
if (layouts[current]) return current;
|
|
420
|
+
current = getParentDir(current);
|
|
421
|
+
}
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Wrap element with loading (Suspense) and error boundary if available for a dir
|
|
426
|
+
function wrapElement(element, dir) {
|
|
427
|
+
let wrapped = element;
|
|
428
|
+
if (errors[dir]) {
|
|
429
|
+
wrapped = createElement(VoltxErrorBoundary, { fallback: errors[dir] }, wrapped);
|
|
430
|
+
}
|
|
431
|
+
if (loadings[dir]) {
|
|
432
|
+
wrapped = createElement(Suspense, { fallback: createElement(loadings[dir]) }, wrapped);
|
|
433
|
+
}
|
|
434
|
+
return wrapped;
|
|
315
435
|
}
|
|
316
436
|
|
|
317
|
-
|
|
437
|
+
// \u2500\u2500\u2500 Layout wrapper component \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
438
|
+
// A layout component that renders the layout with <Outlet /> for children
|
|
439
|
+
function createLayoutElement(LayoutComp, dir) {
|
|
440
|
+
// The layout component receives <Outlet /> as its children rendering point
|
|
441
|
+
// We create a wrapper that renders Layout with Outlet inside
|
|
442
|
+
function LayoutWrapper() {
|
|
443
|
+
return createElement(LayoutComp, null, createElement(Outlet));
|
|
444
|
+
}
|
|
445
|
+
LayoutWrapper.displayName = "Layout(" + (dir || "root") + ")";
|
|
446
|
+
return LayoutWrapper;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// \u2500\u2500\u2500 Generate the <Routes> tree \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
450
|
+
function buildRouteElements() {
|
|
451
|
+
const allDirs = getAllDirs();
|
|
452
|
+
|
|
453
|
+
// Build a tree structure: each dir can have child routes and child layout dirs
|
|
454
|
+
// dirChildren[dir] = array of Route elements (pages + nested layout routes)
|
|
455
|
+
const dirChildren = {};
|
|
456
|
+
for (const dir of allDirs) {
|
|
457
|
+
dirChildren[dir] = [];
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// First, add page routes to their directory
|
|
461
|
+
for (const dir of allDirs) {
|
|
462
|
+
const dirPages = pages[dir] || [];
|
|
463
|
+
for (const { routePath, Component: PageComp } of dirPages) {
|
|
464
|
+
// Compute the path relative to the directory's base
|
|
465
|
+
const dirBase = dir ? "/" + dir : "";
|
|
466
|
+
let relativePath = routePath;
|
|
467
|
+
if (dirBase && relativePath.startsWith(dirBase)) {
|
|
468
|
+
relativePath = relativePath.substring(dirBase.length);
|
|
469
|
+
}
|
|
470
|
+
if (relativePath.startsWith("/")) relativePath = relativePath.substring(1);
|
|
471
|
+
|
|
472
|
+
const isIndex = relativePath === "";
|
|
473
|
+
const routeProps = isIndex
|
|
474
|
+
? { index: true, element: createElement(PageComp), key: routePath }
|
|
475
|
+
: { path: relativePath, element: createElement(PageComp), key: routePath };
|
|
476
|
+
|
|
477
|
+
dirChildren[dir].push(createElement(Route, routeProps));
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Now, nest directories bottom-up: child dirs become Route children of parent dirs
|
|
482
|
+
// Process in reverse order (deepest first)
|
|
483
|
+
const reversedDirs = allDirs.slice().reverse();
|
|
484
|
+
|
|
485
|
+
for (const dir of reversedDirs) {
|
|
486
|
+
if (dir === "") continue; // root handled last
|
|
487
|
+
|
|
488
|
+
const parentDir = getParentDir(dir);
|
|
489
|
+
if (parentDir === null) continue;
|
|
490
|
+
|
|
491
|
+
const hasLayout = !!layouts[dir];
|
|
492
|
+
const children = dirChildren[dir];
|
|
493
|
+
|
|
494
|
+
// Compute path segment for this directory relative to parent
|
|
495
|
+
const parentBase = parentDir ? parentDir + "/" : "";
|
|
496
|
+
const segment = dir.startsWith(parentBase) ? dir.substring(parentBase.length) : dir;
|
|
497
|
+
// Convert [param] to :param in segment
|
|
498
|
+
const routeSegment = segment.replace(/\\[([^\\]]+)\\]/g, ":$1");
|
|
499
|
+
|
|
500
|
+
if (hasLayout) {
|
|
501
|
+
// This dir has a layout \u2014 create a layout route with children
|
|
502
|
+
const LayoutWrapper = createLayoutElement(layouts[dir], dir);
|
|
503
|
+
let layoutElement = createElement(LayoutWrapper);
|
|
504
|
+
layoutElement = wrapElement(layoutElement, dir);
|
|
505
|
+
|
|
506
|
+
const layoutRoute = createElement(
|
|
507
|
+
Route,
|
|
508
|
+
{ path: routeSegment, element: layoutElement, key: "layout:" + dir },
|
|
509
|
+
...children
|
|
510
|
+
);
|
|
511
|
+
dirChildren[parentDir].push(layoutRoute);
|
|
512
|
+
} else if (children.length > 0) {
|
|
513
|
+
// No layout \u2014 just a path prefix grouping
|
|
514
|
+
if (routeSegment) {
|
|
515
|
+
const prefixRoute = createElement(
|
|
516
|
+
Route,
|
|
517
|
+
{ path: routeSegment, key: "prefix:" + dir },
|
|
518
|
+
...children
|
|
519
|
+
);
|
|
520
|
+
dirChildren[parentDir].push(prefixRoute);
|
|
521
|
+
} else {
|
|
522
|
+
// Empty segment \u2014 push children directly
|
|
523
|
+
dirChildren[parentDir].push(...children);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Build root routes
|
|
529
|
+
const rootChildren = dirChildren[""];
|
|
530
|
+
|
|
531
|
+
// Add not-found catch-all at the end
|
|
532
|
+
if (notFound) {
|
|
533
|
+
rootChildren.push(
|
|
534
|
+
createElement(Route, { path: "*", element: createElement(notFound), key: "not-found" })
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// If root has a layout, wrap everything in it
|
|
539
|
+
if (layouts[""]) {
|
|
540
|
+
const RootLayout = createLayoutElement(layouts[""], "");
|
|
541
|
+
let rootElement = createElement(RootLayout);
|
|
542
|
+
rootElement = wrapElement(rootElement, "");
|
|
543
|
+
|
|
544
|
+
return createElement(
|
|
545
|
+
Routes,
|
|
546
|
+
null,
|
|
547
|
+
createElement(Route, { path: "/", element: rootElement }, ...rootChildren)
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// No root layout \u2014 wrap with loading/error if present
|
|
552
|
+
let routesElement = createElement(Routes, null, ...rootChildren);
|
|
553
|
+
if (errors[""]) {
|
|
554
|
+
routesElement = createElement(VoltxErrorBoundary, { fallback: errors[""] }, routesElement);
|
|
555
|
+
}
|
|
556
|
+
if (loadings[""]) {
|
|
557
|
+
routesElement = createElement(Suspense, { fallback: createElement(loadings[""]) }, routesElement);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
return routesElement;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// \u2500\u2500\u2500 Collect flat routes list (for backward compat) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
564
|
+
function collectRoutes() {
|
|
565
|
+
const result = [];
|
|
566
|
+
for (const dirPages of Object.values(pages)) {
|
|
567
|
+
for (const { routePath, Component } of dirPages) {
|
|
568
|
+
result.push({ path: routePath, Component });
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return result;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const routes = collectRoutes();
|
|
318
575
|
|
|
319
576
|
export function VoltxRoutes() {
|
|
320
|
-
return
|
|
321
|
-
Routes,
|
|
322
|
-
null,
|
|
323
|
-
routes.map(({ path, Component }) =>
|
|
324
|
-
createElement(Route, { key: path, path, element: createElement(Component) })
|
|
325
|
-
)
|
|
326
|
-
);
|
|
577
|
+
return buildRouteElements();
|
|
327
578
|
}
|
|
328
579
|
|
|
329
580
|
export { routes };
|
|
330
581
|
|
|
331
582
|
// Navigation primitives \u2014 single import source
|
|
332
|
-
export { Link, NavLink, useNavigate, useParams, useLocation, useSearchParams } from "react-router";
|
|
583
|
+
export { Link, NavLink, Outlet, useNavigate, useParams, useLocation, useSearchParams, useOutletContext } from "react-router";
|
|
584
|
+
`;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// src/vite-api-plugin.ts
|
|
588
|
+
function voltxAPI(options = {}) {
|
|
589
|
+
const apiDir = options.apiDir ?? "api";
|
|
590
|
+
const PUBLIC_ID = "voltx/api";
|
|
591
|
+
const RESOLVED_ID = "\0voltx/api";
|
|
592
|
+
return {
|
|
593
|
+
name: "voltx-api",
|
|
594
|
+
enforce: "pre",
|
|
595
|
+
resolveId(id) {
|
|
596
|
+
if (id === PUBLIC_ID) {
|
|
597
|
+
return RESOLVED_ID;
|
|
598
|
+
}
|
|
599
|
+
},
|
|
600
|
+
load(id) {
|
|
601
|
+
if (id === RESOLVED_ID) {
|
|
602
|
+
return `
|
|
603
|
+
const modules = import.meta.glob("/${apiDir}/**/*.ts", { eager: true });
|
|
604
|
+
|
|
605
|
+
const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"];
|
|
606
|
+
|
|
607
|
+
function fileToRoute(filePath) {
|
|
608
|
+
let route = filePath
|
|
609
|
+
.replace("/${apiDir}", "/${apiDir}")
|
|
610
|
+
.replace(/\\.ts$/, "")
|
|
611
|
+
.replace(/\\/index$/, "");
|
|
612
|
+
|
|
613
|
+
// Convert [param] -> :param
|
|
614
|
+
route = route.replace(/\\[([^\\]\\.]+)\\]/g, ":$1");
|
|
615
|
+
// Convert [...slug] -> *
|
|
616
|
+
route = route.replace(/\\[\\.\\.\\.([^\\]]+)\\]/g, "*");
|
|
617
|
+
|
|
618
|
+
if (!route || route === "/${apiDir}") route = "/${apiDir}";
|
|
619
|
+
return route;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export function registerRoutes(app) {
|
|
623
|
+
const registered = [];
|
|
624
|
+
|
|
625
|
+
// Sort: static routes first, dynamic (:param) second, catch-all (*) last
|
|
626
|
+
const entries = Object.entries(modules).sort(([a], [b]) => {
|
|
627
|
+
const ra = fileToRoute(a);
|
|
628
|
+
const rb = fileToRoute(b);
|
|
629
|
+
const sa = ra.includes("*") ? 2 : ra.includes(":") ? 1 : 0;
|
|
630
|
+
const sb = rb.includes("*") ? 2 : rb.includes(":") ? 1 : 0;
|
|
631
|
+
if (sa !== sb) return sa - sb;
|
|
632
|
+
return ra.localeCompare(rb);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
for (const [filePath, mod] of entries) {
|
|
636
|
+
const route = fileToRoute(filePath);
|
|
637
|
+
|
|
638
|
+
for (const method of HTTP_METHODS) {
|
|
639
|
+
const handler = mod[method];
|
|
640
|
+
if (typeof handler === "function") {
|
|
641
|
+
app.on(method, route, handler);
|
|
642
|
+
registered.push({ method, path: route });
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
return registered;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
export { modules as apiModules };
|
|
333
651
|
`;
|
|
334
652
|
}
|
|
335
653
|
},
|
|
654
|
+
// HMR: when a file in api/ is added/removed, invalidate the virtual module
|
|
336
655
|
handleHotUpdate({ file, server }) {
|
|
337
|
-
if (file.includes(
|
|
656
|
+
if (file.includes(`/${apiDir}/`) || file.endsWith(`/${apiDir}`)) {
|
|
338
657
|
const mod = server.moduleGraph.getModuleById(RESOLVED_ID);
|
|
339
658
|
if (mod) {
|
|
340
659
|
server.moduleGraph.invalidateModule(mod);
|
|
@@ -485,7 +804,7 @@ ${cssLinks}
|
|
|
485
804
|
}
|
|
486
805
|
|
|
487
806
|
// src/index.ts
|
|
488
|
-
var VERSION = "0.4.
|
|
807
|
+
var VERSION = "0.4.7";
|
|
489
808
|
export {
|
|
490
809
|
Hono2 as Hono,
|
|
491
810
|
VERSION,
|
|
@@ -498,5 +817,6 @@ export {
|
|
|
498
817
|
registerSSR,
|
|
499
818
|
registerStaticFiles,
|
|
500
819
|
scanAndRegisterRoutes,
|
|
820
|
+
voltxAPI,
|
|
501
821
|
voltxRouter
|
|
502
822
|
};
|
package/package.json
CHANGED