@ubean/vue 0.2.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.
@@ -0,0 +1,369 @@
1
+ //#region src/generator/index.ts
2
+ const DEFAULT_HEADER_COMMENT = "// @generated by @ubean/vue/generator — do not edit manually.\n// This file is regenerated on every dev start / build.";
3
+ async function generateRouteFiles(scan, options) {
4
+ return new RouteFileGenerator(options).generate(scan);
5
+ }
6
+ var RouteFileGenerator = class {
7
+ opts;
8
+ constructor(options) {
9
+ const dtsPath = options.dtsPath ?? joinPosix(options.outDir, "typed-router.d.ts");
10
+ this.opts = {
11
+ cwd: options.cwd,
12
+ srcDir: options.srcDir ?? joinPosix(options.cwd, "src"),
13
+ outDir: options.outDir,
14
+ dtsPath,
15
+ generateRoutes: options.generateRoutes ?? true,
16
+ generateImports: options.generateImports ?? true,
17
+ generateDts: options.generateDts ?? true,
18
+ routeLazy: options.routeLazy ?? true,
19
+ layoutLazy: options.layoutLazy ?? true,
20
+ headerComment: options.headerComment ?? "// @generated by @ubean/vue/generator — do not edit manually.\n// This file is regenerated on every dev start / build.",
21
+ getRouteMeta: options.getRouteMeta,
22
+ getImportPath: options.getImportPath,
23
+ getLayoutImportPath: options.getLayoutImportPath
24
+ };
25
+ }
26
+ async generate(scan) {
27
+ const { cwd, outDir, dtsPath, generateRoutes, generateImports, generateDts } = this.opts;
28
+ const routesPath = generateRoutes ? joinPosix(outDir, "routes.ts") : void 0;
29
+ const importsPath = generateImports ? joinPosix(outDir, "imports.ts") : void 0;
30
+ const finalDtsPath = generateDts ? dtsPath : void 0;
31
+ await Promise.all([
32
+ routesPath ? writeFile(resolvePath(cwd, routesPath), this.renderRoutesFile(scan)) : null,
33
+ importsPath ? writeFile(resolvePath(cwd, importsPath), this.renderImportsFile(scan)) : null,
34
+ finalDtsPath ? writeFile(resolvePath(cwd, finalDtsPath), this.renderDtsFile(scan)) : null
35
+ ]);
36
+ return {
37
+ routesPath,
38
+ importsPath,
39
+ dtsPath: finalDtsPath,
40
+ routeCount: scan.pages.length,
41
+ layoutCount: scan.layouts.length
42
+ };
43
+ }
44
+ renderRoutesFile(scan) {
45
+ return `${this.opts.headerComment}
46
+
47
+ import type { RouteRecord } from './imports';
48
+
49
+ export const routes: RouteRecord[] = [
50
+ ${scan.pages.map((p) => this.renderRouteRecord(p)).join(",\n")}
51
+ ];
52
+ `;
53
+ }
54
+ renderRouteRecord(page) {
55
+ const parts = [];
56
+ parts.push(` name: ${JSON.stringify(page.name)}`);
57
+ parts.push(` path: ${JSON.stringify(page.route)}`);
58
+ const componentKey = page.isReuse && page.reuseTarget ? page.reuseTarget : page.name;
59
+ parts.push(` component: ${JSON.stringify(componentKey)}`);
60
+ if (page.layout !== void 0) {
61
+ let layoutVal;
62
+ if (page.layout === false) layoutVal = "false";
63
+ else if (Array.isArray(page.layout)) layoutVal = JSON.stringify(page.layout);
64
+ else layoutVal = JSON.stringify(page.layout);
65
+ parts.push(` layout: ${layoutVal}`);
66
+ }
67
+ if (page.isReuse) parts.push(` reuse: true`);
68
+ const meta = this.computeMeta(page);
69
+ if (meta && Object.keys(meta).length > 0) parts.push(` meta: ${JSON.stringify(meta)}`);
70
+ if (page.pageMeta?.cache === true) parts.push(` cache: true`);
71
+ if (page.pageMeta?.requiresAuth === true) parts.push(` requiresAuth: true`);
72
+ return ` {\n${parts.join(",\n")}\n }`;
73
+ }
74
+ computeMeta(page) {
75
+ const base = page.pageMeta?.meta ?? {};
76
+ const merged = {
77
+ ...this.opts.getRouteMeta?.(page) ?? {},
78
+ ...base
79
+ };
80
+ if (page.matchers && Object.keys(page.matchers).length > 0) merged.matchers = page.matchers;
81
+ return Object.keys(merged).length > 0 ? merged : null;
82
+ }
83
+ renderImportsFile(scan) {
84
+ const header = this.opts.headerComment;
85
+ const layouts = scan.layouts.map((l) => this.renderLayoutImport(l)).join(",\n");
86
+ const views = scan.pages.filter((p) => !p.isReuse).map((p) => this.renderViewImport(p)).join(",\n");
87
+ const layoutType = scan.layouts.length > 0 ? `'${scan.layouts.map((l) => l.name).join("' | '")}'` : "string";
88
+ const filePages = scan.pages.filter((p) => !p.isReuse);
89
+ const reusePages = scan.pages.filter((p) => p.isReuse);
90
+ return `${header}
91
+
92
+ export type LayoutKey = ${layoutType};
93
+
94
+ /**
95
+ * Route keys that have a corresponding source file (\`.vue\` / \`.md\`).
96
+ * Excludes reuse routes and builtin routes.
97
+ *
98
+ * The \`views\` map is keyed by this type — \`views[RouteFileKey]\` resolves
99
+ * to the actual component loader.
100
+ */
101
+ export type RouteFileKey = ${filePages.length > 0 ? `'${filePages.map((p) => p.name).join("' | '")}'` : "never"};
102
+
103
+ /**
104
+ * Route keys declared via \`.reuse.ts\` / \`.reuse.js\` metadata files.
105
+ * They reference another route's component via \`reuseTarget\`.
106
+ */
107
+ export type RouteReuseKey = ${reusePages.length > 0 ? `'${reusePages.map((p) => p.name).join("' | '")}'` : "never"};
108
+
109
+ /**
110
+ * Builtin route keys (framework-provided, e.g. \`root\` / \`not_found\`).
111
+ * Currently unused — reserved for future builtin pages.
112
+ */
113
+ export type BuiltinRouteKey = never;
114
+
115
+ /**
116
+ * All page route names. Union of {@link RouteFileKey}, {@link RouteReuseKey}
117
+ * and {@link BuiltinRouteKey}.
118
+ */
119
+ export type RouteKey = ${scan.pages.length > 0 ? `'${scan.pages.map((p) => p.name).join("' | '")}'` : "never"};
120
+
121
+ export type Lazy<T> = () => Promise<T>;
122
+ export type RawRouteComponent = import('vue').Component | Lazy<import('vue').Component>;
123
+
124
+ export interface RouteRecord {
125
+ name: RouteKey;
126
+ path: string;
127
+ /**
128
+ * Key into the \`views\` map — resolve the actual component via
129
+ * \`views[route.component]\`.
130
+ *
131
+ * For reuse routes, this is the \`reuseTarget\`'s \`RouteFileKey\`
132
+ * (not the reuse route's own name), so the same component is reused.
133
+ */
134
+ component: RouteFileKey;
135
+ layout?: LayoutKey | LayoutKey[] | false;
136
+ reuse?: boolean;
137
+ meta?: Record<string, unknown>;
138
+ cache?: boolean;
139
+ requiresAuth?: boolean;
140
+ }
141
+
142
+ export const layouts: Record<LayoutKey, RawRouteComponent> = {
143
+ ${layouts}
144
+ };
145
+
146
+ export const views: Record<RouteFileKey, RawRouteComponent> = {
147
+ ${views}
148
+ };
149
+ `;
150
+ }
151
+ renderLayoutImport(layout) {
152
+ const importPath = this.opts.getLayoutImportPath?.(layout) ?? defaultLayoutImportPath(layout, this.opts.srcDir);
153
+ if (typeof this.opts.layoutLazy === "function" ? this.opts.layoutLazy(layout) : this.opts.layoutLazy) return ` ${layout.name}: () => import(${JSON.stringify(importPath)})`;
154
+ const importName = `__layout_${layout.name}`;
155
+ return ` ${layout.name}: ${importName}, // import ${importName} from ${JSON.stringify(importPath)}`;
156
+ }
157
+ renderViewImport(page) {
158
+ const importPath = this.opts.getImportPath?.(page) ?? defaultPageImportPath(page, this.opts.srcDir);
159
+ if (typeof this.opts.routeLazy === "function" ? this.opts.routeLazy(page) : this.opts.routeLazy) return ` ${page.name}: () => import(${JSON.stringify(importPath)})`;
160
+ return ` ${page.name}: ${page.name}, // import ${page.name} from ${JSON.stringify(importPath)}`;
161
+ }
162
+ renderDtsFile(scan) {
163
+ const header = this.opts.headerComment;
164
+ const pathMapEntries = scan.pages.length > 0 ? scan.pages.map((p) => ` "${p.name}": "${p.route}";`).join("\n") : " // (no pages scanned)";
165
+ const reuseRoutes = scan.pages.filter((p) => p.isReuse);
166
+ const reuseKeys = reuseRoutes.length > 0 ? reuseRoutes.map((p) => `"${p.name}"`).join(" | ") : "never";
167
+ return `${header}
168
+
169
+ declare module '@ubean/scan' {
170
+ /**
171
+ * Route layout keys scanned from \`src/layouts/\`.
172
+ */
173
+ export type RouteLayoutKey = ${scan.layouts.length > 0 ? scan.layouts.map((l) => `"${l.name}"`).join(" | ") : "never"};
174
+
175
+ /**
176
+ * Map of route name → route path, scanned from \`src/pages/\`.
177
+ */
178
+ export interface RoutePathMap {
179
+ ${pathMapEntries}
180
+ }
181
+
182
+ /**
183
+ * Route names (keys of {@link RoutePathMap}).
184
+ */
185
+ export type RouteKey = keyof RoutePathMap;
186
+
187
+ /**
188
+ * Route paths (values of {@link RoutePathMap}).
189
+ */
190
+ export type RoutePath = RoutePathMap[RouteKey];
191
+
192
+ /**
193
+ * Reuse route keys (pages using \`.reuse.ts\` / \`.reuse.js\` metadata files).
194
+ */
195
+ export type ReuseRouteKey = ${reuseKeys};
196
+
197
+ /**
198
+ * Builtin route keys (framework-provided, e.g. \`root\` / \`not_found\`).
199
+ * Currently unused — reserved for future builtin pages.
200
+ */
201
+ export type BuiltinRouteKey = never;
202
+
203
+ /**
204
+ * Route keys that have a corresponding source file (\`.vue\` / \`.md\`).
205
+ * Excludes reuse routes and builtin routes.
206
+ *
207
+ * Use this to index the \`views\` map for component lookup.
208
+ */
209
+ export type RouteFileKey = Exclude<RouteKey, ReuseRouteKey | BuiltinRouteKey>;
210
+ }
211
+
212
+ declare module 'vue-router/auto-routes' {
213
+ import type { RouteRecordInfo, ParamValue, ParamValueZeroOrOne } from 'vue-router';
214
+
215
+ /**
216
+ * Route named map — enables typed \`useRoute<Name>(name)\` via vue-router's
217
+ * \`TypesConfig\` augmentation below.
218
+ *
219
+ * Each entry describes a route's name, path, and the types of its path
220
+ * params (raw input vs. resolved value). For routes without params,
221
+ * \`Record<never, never>\` is used.
222
+ *
223
+ * @see https://router.vuejs.org/api/interfaces/RouteRecordInfo.html
224
+ */
225
+ export interface RouteNamedMap {
226
+ ${scan.pages.length > 0 ? scan.pages.map((p) => ` "${p.name}": ${this.renderRouteRecordInfo(p)};`).join("\n") : " // (no pages scanned)"}
227
+ }
228
+ }
229
+
230
+ declare module 'vue-router' {
231
+ export interface TypesConfig {
232
+ RouteNamedMap: import('vue-router/auto-routes').RouteNamedMap;
233
+ }
234
+ }
235
+
236
+ export type { RouteLayoutKey, RoutePathMap, RouteKey, RoutePath, ReuseRouteKey, BuiltinRouteKey, RouteFileKey } from '@ubean/scan';
237
+ `;
238
+ }
239
+ /**
240
+ * 渲染单条路由的 `RouteRecordInfo<Name, Path, ParamsRaw, Params>`。
241
+ *
242
+ * 参数类型映射:
243
+ * - 必需参数(`/users/:id`) → `{ id: ParamValue<true> }` / `{ id: ParamValue<false> }`
244
+ * - 可选参数(`/users/:id?`) → `{ id?: ParamValueZeroOrOne<true> }` / `{ id?: ParamValueZeroOrOne<false> }`
245
+ * - 无参数(`/about`) → `Record<never, never>`
246
+ *
247
+ * `ParamValue<true>` 表示原始输入类型(必需为 string),`ParamValue<false>` 表示
248
+ * 解析后类型(已编码/解码,可能为 `string | undefined`)。这是 vue-router 的约定。
249
+ */
250
+ renderRouteRecordInfo(page) {
251
+ const params = extractRouteParams(page.route);
252
+ const paramsRaw = renderParamsType(params, true);
253
+ const paramsResolved = renderParamsType(params, false);
254
+ return `RouteRecordInfo<${JSON.stringify(page.name)}, ${JSON.stringify(page.route)}, ${paramsRaw}, ${paramsResolved}>`;
255
+ }
256
+ };
257
+ /**
258
+ * Extensions stripped when computing the default import specifier.
259
+ *
260
+ * `.vue` / `.md` / `.mdx` are KEPT — Vite dispatches them to dedicated
261
+ * transforms (vue-loader / markdown) based on the extension, so the
262
+ * explicit suffix is required for correct resolution at runtime.
263
+ *
264
+ * `.ts` / `.tsx` / `.js` / `.jsx` are STRIPPED — TypeScript/Vite convention
265
+ * is to import script modules without their extension.
266
+ */
267
+ const STRIPPABLE_IMPORT_EXT = /\.(tsx?|jsx?)$/;
268
+ /**
269
+ * Compute the default import specifier for a page.
270
+ *
271
+ * Uses `srcDir` (not `cwd`) as the relative base so that the resulting
272
+ * `@/<rel>` path matches the common `@/` → `src/` path alias. For example,
273
+ * a page at `<cwd>/src/pages/about.vue` becomes `@/pages/about.vue`
274
+ * (NOT `@/src/pages/about.vue`, which would double the `src/` prefix and
275
+ * fail to resolve under the standard alias).
276
+ */
277
+ function defaultPageImportPath(page, srcDir) {
278
+ return `@/${relativePosix(srcDir, page.fullPath).replace(STRIPPABLE_IMPORT_EXT, "")}`;
279
+ }
280
+ function defaultLayoutImportPath(layout, srcDir) {
281
+ return `@/${relativePosix(srcDir, layout.fullPath).replace(STRIPPABLE_IMPORT_EXT, "")}`;
282
+ }
283
+ /**
284
+ * 从 vue-router 风格路径中提取参数信息。
285
+ *
286
+ * 支持的语法:
287
+ * - `:name` — 必需参数(如 `/users/:id`)
288
+ * - `:name?` — 可选参数(如 `/users/:id?`)
289
+ * - `:name*` — 重复参数(零或多个,如 `/files/:path*`)— 当前当作可选处理
290
+ * - `:name+` — 重复参数(一个或多个,如 `/files/:path+`)— 当前当作必需处理
291
+ * - `:name(...)` — 带自定义正则的参数(如 `:id(\\d+)`)— 提取 `name`,忽略正则
292
+ *
293
+ * 不识别 `:pathMatch(.*)*`(catch-all),由 vue-router 内部处理,在
294
+ * `RouteRecordInfo` 中视为无参数(`Record<never, never>`)。
295
+ *
296
+ * @returns 参数数组,顺序与 path 中出现顺序一致
297
+ */
298
+ function extractRouteParams(path) {
299
+ const params = [];
300
+ const paramRegex = /:([A-Za-z_][A-Za-z0-9_]*)(?:\([^)]*\))?([?*+]?)/g;
301
+ let match;
302
+ while ((match = paramRegex.exec(path)) !== null) {
303
+ const name = match[1];
304
+ const modifier = match[2];
305
+ const optional = modifier === "?" || modifier === "*";
306
+ if (!params.some((p) => p.name === name)) params.push({
307
+ name,
308
+ optional
309
+ });
310
+ }
311
+ return params;
312
+ }
313
+ /**
314
+ * 渲染参数类型字面量,用于 `RouteRecordInfo` 的 `ParamsRaw` / `Params` 类型参数位置。
315
+ *
316
+ * @param params `extractRouteParams` 返回的参数数组
317
+ * @param isRaw `true` 渲染原始输入类型(`ParamValue<true>` / `ParamValueZeroOrOne<true>`),
318
+ * `false` 渲染解析后类型(`ParamValue<false>` / `ParamValueZeroOrOne<false>`)
319
+ * @returns 类型字面量字符串,如 `{ id: ParamValue<true> }` 或 `Record<never, never>`
320
+ */
321
+ function renderParamsType(params, isRaw) {
322
+ if (params.length === 0) return "Record<never, never>";
323
+ return `{\n${params.map((p) => {
324
+ const optionalMarker = p.optional ? "?" : "";
325
+ const type = p.optional ? `ParamValueZeroOrOne<${isRaw ? "true" : "false"}>` : `ParamValue<${isRaw ? "true" : "false"}>`;
326
+ return ` ${p.name}${optionalMarker}: ${type}`;
327
+ }).join(",\n")}\n }`;
328
+ }
329
+ function joinPosix(...parts) {
330
+ return parts.map((p) => p.replace(/\\/g, "/")).join("/").replace(/\/+/g, "/");
331
+ }
332
+ /**
333
+ * 将 `cwd` 与 `target` 合并为最终写入路径。
334
+ *
335
+ * 与 `joinPosix` 不同,此函数会识别 `target` 是否为绝对路径:
336
+ * - 如果 `target` 以 `/` 开头(POSIX 绝对路径)或盘符开头(Windows,如 `C:`),
337
+ * 直接使用 `target`,忽略 `cwd`
338
+ * - 否则,使用 `joinPosix(cwd, target)` 拼接
339
+ *
340
+ * 这是为了支持消费方传入 `path.resolve()` 计算出的绝对路径,
341
+ * 避免出现 `<cwd>/<absolute-target>` 这种重复前缀的错误路径(参见
342
+ * builder `maybeGenerateRouteFiles` 中 `resolve(config.rootDir, ...)` 的调用)。
343
+ */
344
+ function resolvePath(cwd, target) {
345
+ const normalized = target.replace(/\\/g, "/");
346
+ if (normalized.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(normalized)) return normalized;
347
+ return joinPosix(cwd, normalized);
348
+ }
349
+ function relativePosix(from, to) {
350
+ const fromParts = resolvePosix(from).split("/").filter(Boolean);
351
+ const toParts = resolvePosix(to).split("/").filter(Boolean);
352
+ let i = 0;
353
+ while (i < fromParts.length && i < toParts.length && fromParts[i] === toParts[i]) i++;
354
+ const up = fromParts.slice(i).map(() => "..");
355
+ const down = toParts.slice(i);
356
+ return [...up, ...down].join("/") || ".";
357
+ }
358
+ function resolvePosix(p) {
359
+ if (p.startsWith("/")) return p.replace(/\\/g, "/");
360
+ return p.replace(/\\/g, "/");
361
+ }
362
+ async function writeFile(path, content) {
363
+ const { mkdir, writeFile: fsWriteFile } = await import("node:fs/promises");
364
+ const { dirname } = await import("node:path");
365
+ await mkdir(dirname(path), { recursive: true });
366
+ await fsWriteFile(path, content, "utf-8");
367
+ }
368
+ //#endregion
369
+ export { DEFAULT_HEADER_COMMENT, RouteFileGenerator, generateRouteFiles };