@tcfed/vite-plugin-page-routes 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +15 -0
- package/dist/index.js +316 -0
- package/package.json +31 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Plugin } from "vite";
|
|
2
|
+
export interface PageRoutesPluginOptions {
|
|
3
|
+
pagesDir?: string;
|
|
4
|
+
routesFile?: string;
|
|
5
|
+
routePath?: string;
|
|
6
|
+
}
|
|
7
|
+
interface GeneratedPageRoute {
|
|
8
|
+
path: string;
|
|
9
|
+
title: string;
|
|
10
|
+
modulePath: string;
|
|
11
|
+
examplePath?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function createPageRoutesPlugin(options?: PageRoutesPluginOptions): Plugin;
|
|
14
|
+
export declare function scanPageRoutes(root: string, pagesDir?: string, routesFile?: string): GeneratedPageRoute[];
|
|
15
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export function createPageRoutesPlugin(options = {}) {
|
|
4
|
+
const routePath = normalizeDevRoutePath(options.routePath ?? "/__pages");
|
|
5
|
+
return {
|
|
6
|
+
name: "vite-plugin-page-routes",
|
|
7
|
+
apply: "serve",
|
|
8
|
+
enforce: "pre",
|
|
9
|
+
configureServer(server) {
|
|
10
|
+
registerPagesMiddleware(server, routePath, options.pagesDir, options.routesFile);
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function scanPageRoutes(root, pagesDir = "src/pages", routesFile = "src/App.tsx") {
|
|
15
|
+
const appRoutes = scanReactRouterRoutes(root, routesFile);
|
|
16
|
+
if (appRoutes.length > 0)
|
|
17
|
+
return appRoutes;
|
|
18
|
+
return scanPageDirectories(root, pagesDir);
|
|
19
|
+
}
|
|
20
|
+
function scanReactRouterRoutes(root, routesFile = "src/App.tsx") {
|
|
21
|
+
const absoluteRoutesFile = path.resolve(root, routesFile);
|
|
22
|
+
if (!fs.existsSync(absoluteRoutesFile))
|
|
23
|
+
return [];
|
|
24
|
+
const content = fs.readFileSync(absoluteRoutesFile, "utf-8");
|
|
25
|
+
const modulesByComponent = new Map();
|
|
26
|
+
for (const match of content.matchAll(/const\s+([A-Za-z_$][\w$]*)\s*=\s*lazy\(\s*\(\)\s*=>\s*import\(\s*["']([^"']+)["']\s*\)\s*\)/g)) {
|
|
27
|
+
modulesByComponent.set(match[1], match[2]);
|
|
28
|
+
}
|
|
29
|
+
const routes = [];
|
|
30
|
+
for (const match of content.matchAll(/<Route\b[^>]*\bpath=["']([^"']+)["'][^>]*\belement=\{<([A-Za-z_$][\w$]*)\s*\/>\}[^>]*\/>/g)) {
|
|
31
|
+
const modulePath = modulesByComponent.get(match[2]);
|
|
32
|
+
if (!modulePath?.startsWith("@/pages/"))
|
|
33
|
+
continue;
|
|
34
|
+
routes.push({
|
|
35
|
+
path: match[1],
|
|
36
|
+
title: inferTitle(modulePath.replace(/^@\/pages\/?/, "").split("/")),
|
|
37
|
+
modulePath,
|
|
38
|
+
examplePath: inferExamplePath(match[1]),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return routes;
|
|
42
|
+
}
|
|
43
|
+
function scanPageDirectories(root, pagesDir) {
|
|
44
|
+
const absolutePagesDir = path.resolve(root, pagesDir);
|
|
45
|
+
if (!fs.existsSync(absolutePagesDir))
|
|
46
|
+
return [];
|
|
47
|
+
const routes = [];
|
|
48
|
+
for (const indexFile of findPageIndexFiles(absolutePagesDir)) {
|
|
49
|
+
const pageDir = path.dirname(indexFile);
|
|
50
|
+
const relativeDir = toPosix(path.relative(absolutePagesDir, pageDir));
|
|
51
|
+
const segments = relativeDir.split("/").filter(Boolean);
|
|
52
|
+
if (segments.some((segment) => segment.startsWith("__")))
|
|
53
|
+
continue;
|
|
54
|
+
const routePath = inferRoutePath(segments, pageDir);
|
|
55
|
+
routes.push({
|
|
56
|
+
path: routePath,
|
|
57
|
+
title: inferTitle(segments),
|
|
58
|
+
modulePath: `@/${toPosix(path.relative(path.resolve(root, "src"), pageDir))}`,
|
|
59
|
+
examplePath: inferExamplePath(routePath),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return routes.sort((a, b) => {
|
|
63
|
+
if (a.path === "/")
|
|
64
|
+
return -1;
|
|
65
|
+
if (b.path === "/")
|
|
66
|
+
return 1;
|
|
67
|
+
return a.path.localeCompare(b.path);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
function findPageIndexFiles(dir) {
|
|
71
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
72
|
+
const files = [];
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
const entryPath = path.join(dir, entry.name);
|
|
75
|
+
if (entry.isDirectory()) {
|
|
76
|
+
files.push(...findPageIndexFiles(entryPath));
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (entry.isFile() && entry.name === "index.tsx") {
|
|
80
|
+
files.push(entryPath);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return files;
|
|
84
|
+
}
|
|
85
|
+
function inferRoutePath(segments, pageDir) {
|
|
86
|
+
if (segments.length === 1 && segments[0] === "home")
|
|
87
|
+
return "/";
|
|
88
|
+
const routeSegments = segments.map((segment) => {
|
|
89
|
+
if (segment.startsWith("[") && segment.endsWith("]")) {
|
|
90
|
+
return `:${segment.slice(1, -1)}`;
|
|
91
|
+
}
|
|
92
|
+
return segment;
|
|
93
|
+
});
|
|
94
|
+
for (const paramName of inferParamNames(pageDir)) {
|
|
95
|
+
if (!routeSegments.includes(`:${paramName}`)) {
|
|
96
|
+
routeSegments.push(`:${paramName}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return `/${routeSegments.join("/")}`;
|
|
100
|
+
}
|
|
101
|
+
function inferParamNames(pageDir) {
|
|
102
|
+
const names = new Set();
|
|
103
|
+
const files = fs
|
|
104
|
+
.readdirSync(pageDir, { withFileTypes: true })
|
|
105
|
+
.filter((entry) => entry.isFile() && /\.tsx?$/.test(entry.name))
|
|
106
|
+
.map((entry) => path.join(pageDir, entry.name));
|
|
107
|
+
for (const file of files) {
|
|
108
|
+
const content = fs.readFileSync(file, "utf-8");
|
|
109
|
+
for (const match of content.matchAll(/\{\s*([A-Za-z_$][\w$]*)\s*(?::[^,}]+)?\s*\}\s*=\s*useParams/g)) {
|
|
110
|
+
names.add(match[1]);
|
|
111
|
+
}
|
|
112
|
+
for (const match of content.matchAll(/useParams\s*<\s*\{\s*([^}]+)\}/g)) {
|
|
113
|
+
const keys = match[1].matchAll(/([A-Za-z_$][\w$]*)\s*:/g);
|
|
114
|
+
for (const key of keys) {
|
|
115
|
+
names.add(key[1]);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return [...names];
|
|
120
|
+
}
|
|
121
|
+
function inferTitle(segments) {
|
|
122
|
+
return segments.join("/") || "home";
|
|
123
|
+
}
|
|
124
|
+
function inferExamplePath(routePath) {
|
|
125
|
+
if (!routePath.includes("/:"))
|
|
126
|
+
return undefined;
|
|
127
|
+
return routePath.replace(/:([A-Za-z_$][\w$]*)/g, (_, name) => {
|
|
128
|
+
if (name.toLowerCase() === "id")
|
|
129
|
+
return "1";
|
|
130
|
+
if (name.toLowerCase().endsWith("id"))
|
|
131
|
+
return `${name}_demo`;
|
|
132
|
+
return "demo";
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
function registerPagesMiddleware(server, routePath, pagesDir, routesFile) {
|
|
136
|
+
server.middlewares.use((req, res, next) => {
|
|
137
|
+
if (!req.url) {
|
|
138
|
+
next();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const pathname = new URL(req.url, "http://localhost").pathname;
|
|
142
|
+
if (pathname !== routePath) {
|
|
143
|
+
next();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const routes = scanPageRoutes(server.config.root, pagesDir, routesFile);
|
|
147
|
+
res.statusCode = 200;
|
|
148
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
149
|
+
res.end(renderPagesHtml(routes));
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
function renderPagesHtml(routes) {
|
|
153
|
+
const routeItems = routes
|
|
154
|
+
.map((route) => {
|
|
155
|
+
const targetPath = route.examplePath ?? route.path;
|
|
156
|
+
const badge = route.examplePath ? '<span class="badge">示例</span>' : "";
|
|
157
|
+
return `<a class="route" href="${escapeHtml(targetPath)}">
|
|
158
|
+
<div class="route-main">
|
|
159
|
+
<div class="route-title">
|
|
160
|
+
<strong>${escapeHtml(route.title)}</strong>
|
|
161
|
+
${badge}
|
|
162
|
+
</div>
|
|
163
|
+
<code>${escapeHtml(route.path)}</code>
|
|
164
|
+
<span>${escapeHtml(route.modulePath)}</span>
|
|
165
|
+
</div>
|
|
166
|
+
<span class="open">↗</span>
|
|
167
|
+
</a>`;
|
|
168
|
+
})
|
|
169
|
+
.join("");
|
|
170
|
+
return `<!doctype html>
|
|
171
|
+
<html lang="zh-CN">
|
|
172
|
+
<head>
|
|
173
|
+
<meta charset="UTF-8" />
|
|
174
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
175
|
+
<title>页面路由</title>
|
|
176
|
+
<style>
|
|
177
|
+
* { box-sizing: border-box; }
|
|
178
|
+
body {
|
|
179
|
+
margin: 0;
|
|
180
|
+
min-height: 100vh;
|
|
181
|
+
background: #f9fafb;
|
|
182
|
+
color: #111827;
|
|
183
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
184
|
+
}
|
|
185
|
+
main {
|
|
186
|
+
width: min(100%, 448px);
|
|
187
|
+
min-height: 100vh;
|
|
188
|
+
margin: 0 auto;
|
|
189
|
+
padding-bottom: 32px;
|
|
190
|
+
background: #f9fafb;
|
|
191
|
+
}
|
|
192
|
+
header {
|
|
193
|
+
position: sticky;
|
|
194
|
+
top: 0;
|
|
195
|
+
z-index: 1;
|
|
196
|
+
padding: 14px 16px;
|
|
197
|
+
background: #fff;
|
|
198
|
+
box-shadow: 0 1px 2px rgb(0 0 0 / 0.05);
|
|
199
|
+
}
|
|
200
|
+
h1 {
|
|
201
|
+
margin: 0;
|
|
202
|
+
font-size: 16px;
|
|
203
|
+
line-height: 24px;
|
|
204
|
+
}
|
|
205
|
+
.meta {
|
|
206
|
+
margin-top: 2px;
|
|
207
|
+
color: #6b7280;
|
|
208
|
+
font-size: 12px;
|
|
209
|
+
}
|
|
210
|
+
.list {
|
|
211
|
+
display: grid;
|
|
212
|
+
gap: 12px;
|
|
213
|
+
padding: 16px;
|
|
214
|
+
}
|
|
215
|
+
.route {
|
|
216
|
+
display: flex;
|
|
217
|
+
align-items: flex-start;
|
|
218
|
+
gap: 12px;
|
|
219
|
+
padding: 16px;
|
|
220
|
+
border: 1px solid #f3f4f6;
|
|
221
|
+
border-radius: 8px;
|
|
222
|
+
background: #fff;
|
|
223
|
+
color: inherit;
|
|
224
|
+
text-decoration: none;
|
|
225
|
+
box-shadow: 0 1px 2px rgb(0 0 0 / 0.04);
|
|
226
|
+
}
|
|
227
|
+
.route:active { background: #f9fafb; }
|
|
228
|
+
.route-main {
|
|
229
|
+
min-width: 0;
|
|
230
|
+
flex: 1;
|
|
231
|
+
}
|
|
232
|
+
.route-title {
|
|
233
|
+
display: flex;
|
|
234
|
+
align-items: center;
|
|
235
|
+
gap: 8px;
|
|
236
|
+
min-width: 0;
|
|
237
|
+
}
|
|
238
|
+
strong {
|
|
239
|
+
overflow: hidden;
|
|
240
|
+
text-overflow: ellipsis;
|
|
241
|
+
white-space: nowrap;
|
|
242
|
+
font-size: 14px;
|
|
243
|
+
line-height: 20px;
|
|
244
|
+
}
|
|
245
|
+
.badge {
|
|
246
|
+
flex: none;
|
|
247
|
+
border-radius: 4px;
|
|
248
|
+
padding: 2px 6px;
|
|
249
|
+
background: #fffbeb;
|
|
250
|
+
color: #b45309;
|
|
251
|
+
font-size: 10px;
|
|
252
|
+
font-weight: 600;
|
|
253
|
+
}
|
|
254
|
+
code {
|
|
255
|
+
display: block;
|
|
256
|
+
margin-top: 8px;
|
|
257
|
+
padding: 5px 8px;
|
|
258
|
+
border-radius: 6px;
|
|
259
|
+
background: #f9fafb;
|
|
260
|
+
color: #374151;
|
|
261
|
+
word-break: break-all;
|
|
262
|
+
font: 12px/18px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
263
|
+
}
|
|
264
|
+
span {
|
|
265
|
+
display: block;
|
|
266
|
+
margin-top: 8px;
|
|
267
|
+
overflow: hidden;
|
|
268
|
+
text-overflow: ellipsis;
|
|
269
|
+
white-space: nowrap;
|
|
270
|
+
color: #9ca3af;
|
|
271
|
+
font-size: 12px;
|
|
272
|
+
}
|
|
273
|
+
.open {
|
|
274
|
+
flex: none;
|
|
275
|
+
margin: 1px 0 0;
|
|
276
|
+
color: #9ca3af;
|
|
277
|
+
font-size: 14px;
|
|
278
|
+
}
|
|
279
|
+
</style>
|
|
280
|
+
</head>
|
|
281
|
+
<body>
|
|
282
|
+
<main>
|
|
283
|
+
<header>
|
|
284
|
+
<h1>页面路由</h1>
|
|
285
|
+
<div class="meta">dev only · ${routes.length} 个页面</div>
|
|
286
|
+
</header>
|
|
287
|
+
<section class="list">${routeItems}</section>
|
|
288
|
+
</main>
|
|
289
|
+
</body>
|
|
290
|
+
</html>`;
|
|
291
|
+
}
|
|
292
|
+
function normalizeDevRoutePath(value) {
|
|
293
|
+
const routePath = value.trim();
|
|
294
|
+
if (!routePath || routePath === "/")
|
|
295
|
+
return "/__pages";
|
|
296
|
+
return `/${routePath.replace(/^\/+|\/+$/g, "")}`;
|
|
297
|
+
}
|
|
298
|
+
function escapeHtml(value) {
|
|
299
|
+
return value.replace(/[&<>"']/g, (char) => {
|
|
300
|
+
switch (char) {
|
|
301
|
+
case "&":
|
|
302
|
+
return "&";
|
|
303
|
+
case "<":
|
|
304
|
+
return "<";
|
|
305
|
+
case ">":
|
|
306
|
+
return ">";
|
|
307
|
+
case '"':
|
|
308
|
+
return """;
|
|
309
|
+
default:
|
|
310
|
+
return "'";
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
function toPosix(value) {
|
|
315
|
+
return value.split(path.sep).join("/");
|
|
316
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tcfed/vite-plugin-page-routes",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"import": "./dist/index.js"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
19
|
+
"test": "vitest run src",
|
|
20
|
+
"typecheck": "tsc --noEmit"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^24.12.4",
|
|
24
|
+
"typescript": "~6.0.2",
|
|
25
|
+
"vite": "^8.0.12",
|
|
26
|
+
"vitest": "^4.1.7"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"vite": ">=5"
|
|
30
|
+
}
|
|
31
|
+
}
|