@ubean/cli 0.1.7 → 0.1.9

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,1680 @@
1
+ import { existsSync, promises } from "node:fs";
2
+ import { dirname, extname, join, relative, resolve } from "node:path";
3
+ import { consola } from "consola";
4
+ //#region src/shared/fs-ops.ts
5
+ function resolvePath(cwd, ...paths) {
6
+ return resolve(cwd, ...paths);
7
+ }
8
+ async function ensureDir(dir) {
9
+ await promises.mkdir(dir, { recursive: true });
10
+ }
11
+ async function exists(path) {
12
+ try {
13
+ await promises.access(path);
14
+ return true;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+ function existsSync_(path) {
20
+ return existsSync(path);
21
+ }
22
+ async function readFile(path, encoding = "utf-8") {
23
+ return promises.readFile(path, encoding);
24
+ }
25
+ async function writeFile(path, content, encoding = "utf-8") {
26
+ await ensureDir(dirname(path));
27
+ await promises.writeFile(path, content, encoding);
28
+ }
29
+ async function appendFile(path, content, encoding = "utf-8") {
30
+ await promises.appendFile(path, content, encoding);
31
+ }
32
+ async function remove(path) {
33
+ await promises.rm(path, {
34
+ recursive: true,
35
+ force: true
36
+ });
37
+ }
38
+ async function copyFile(src, dest) {
39
+ await ensureDir(dirname(dest));
40
+ await promises.copyFile(src, dest);
41
+ }
42
+ async function copyDir(src, dest, options) {
43
+ await ensureDir(dest);
44
+ const entries = await promises.readdir(src, { withFileTypes: true });
45
+ for (const entry of entries) {
46
+ const srcPath = join(src, entry.name);
47
+ const destPath = join(dest, entry.name);
48
+ if (options?.filter && !options.filter(srcPath)) continue;
49
+ if (entry.isDirectory()) await copyDir(srcPath, destPath, options);
50
+ else await copyFile(srcPath, destPath);
51
+ }
52
+ }
53
+ async function readDir(path) {
54
+ return promises.readdir(path);
55
+ }
56
+ async function readJson(path) {
57
+ const content = await readFile(path);
58
+ return JSON.parse(content);
59
+ }
60
+ async function writeJson(path, data, indent = 2) {
61
+ await writeFile(path, `${JSON.stringify(data, null, indent)}\n`);
62
+ }
63
+ async function createBackup(path, options = {}) {
64
+ const backupPath = `${path}${options.backupSuffix || ".bak"}`;
65
+ if (!await exists(path)) return null;
66
+ await copyFile(path, backupPath);
67
+ if (options.removeOriginal) await remove(path);
68
+ return backupPath;
69
+ }
70
+ async function restoreBackup(path, options = {}) {
71
+ const backupPath = `${path}${options.backupSuffix || ".bak"}`;
72
+ if (!await exists(backupPath)) return false;
73
+ await copyFile(backupPath, path);
74
+ return true;
75
+ }
76
+ async function removeBackup(path, options = {}) {
77
+ await remove(`${path}${options.backupSuffix || ".bak"}`);
78
+ }
79
+ async function listFiles(dir, pattern) {
80
+ const results = [];
81
+ async function walk(current) {
82
+ const entries = await promises.readdir(current, { withFileTypes: true });
83
+ for (const entry of entries) {
84
+ const fullPath = join(current, entry.name);
85
+ if (entry.isDirectory()) {
86
+ if (entry.name === "node_modules" || entry.name.startsWith(".") && entry.name !== ".ubean") continue;
87
+ await walk(fullPath);
88
+ } else if (!pattern || pattern.test(entry.name)) results.push(fullPath);
89
+ }
90
+ }
91
+ if (await exists(dir)) await walk(dir);
92
+ return results;
93
+ }
94
+ function createFsOps(cwd) {
95
+ return {
96
+ cwd,
97
+ resolve: (...paths) => resolvePath(cwd, ...paths),
98
+ exists: (path) => exists(resolvePath(cwd, path)),
99
+ existsSync: (path) => existsSync_(resolvePath(cwd, path)),
100
+ readFile: (path, enc) => readFile(resolvePath(cwd, path), enc),
101
+ writeFile: (path, content, enc) => writeFile(resolvePath(cwd, path), content, enc),
102
+ appendFile: (path, content, enc) => appendFile(resolvePath(cwd, path), content, enc),
103
+ remove: (path) => remove(resolvePath(cwd, path)),
104
+ ensureDir: (path) => ensureDir(resolvePath(cwd, path)),
105
+ copyFile: (src, dest) => copyFile(resolvePath(cwd, src), resolvePath(cwd, dest)),
106
+ copyDir: (src, dest, opts) => copyDir(resolvePath(cwd, src), resolvePath(cwd, dest), opts),
107
+ readDir: (path) => readDir(resolvePath(cwd, path)),
108
+ readJson: (path) => readJson(resolvePath(cwd, path)),
109
+ writeJson: (path, data, indent) => writeJson(resolvePath(cwd, path), data, indent),
110
+ createBackup: (path, opts) => createBackup(resolvePath(cwd, path), opts),
111
+ restoreBackup: (path, opts) => restoreBackup(resolvePath(cwd, path), opts),
112
+ removeBackup: (path, opts) => removeBackup(resolvePath(cwd, path), opts),
113
+ listFiles: (dir, pattern) => listFiles(resolvePath(cwd, dir || "."), pattern),
114
+ relative: (to) => relative(cwd, resolve(cwd, to))
115
+ };
116
+ }
117
+ //#endregion
118
+ //#region src/shared/templates.ts
119
+ const DEFAULT_DELIMITERS = ["{{", "}}"];
120
+ function renderTemplate(template, options = {}) {
121
+ const vars = options.variables || {};
122
+ const [open, close] = options.delimiters || DEFAULT_DELIMITERS;
123
+ const openEsc = open.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
124
+ const closeEsc = close.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
125
+ const pattern = new RegExp(`${openEsc}\\s*([\\w.]+)\\s*${closeEsc}`, "g");
126
+ return template.replace(pattern, (_match, key) => {
127
+ const value = getNestedValue(vars, key);
128
+ if (value === void 0 || value === null) return `${open}${key}${close}`;
129
+ return String(value);
130
+ });
131
+ }
132
+ function getNestedValue(obj, path) {
133
+ const parts = path.split(".");
134
+ let current = obj;
135
+ for (const part of parts) {
136
+ if (current == null || typeof current !== "object") return void 0;
137
+ current = current[part];
138
+ }
139
+ return current;
140
+ }
141
+ function toKebabCase(str) {
142
+ return str.replace(/([a-z])([A-Z])/g, "$1-$2").replace(/[\s_]+/g, "-").toLowerCase();
143
+ }
144
+ function toPascalCase(str) {
145
+ return str.replace(/[-_\s]+(.)?/g, (_, c) => c ? c.toUpperCase() : "").replace(/^(.)/, (c) => c.toUpperCase());
146
+ }
147
+ function toCamelCase(str) {
148
+ const pascal = toPascalCase(str);
149
+ return pascal.charAt(0).toLowerCase() + pascal.slice(1);
150
+ }
151
+ const PAGE_TEMPLATE = `<script setup lang="ts">
152
+ definePage({
153
+ meta: {
154
+ title: '{{name}}'
155
+ }
156
+ });
157
+ <\/script>
158
+
159
+ <template>
160
+ <div class="{{kebabName}}-page">
161
+ <div>{{name}}</div>
162
+ </div>
163
+ </template>
164
+
165
+ `;
166
+ const API_TEMPLATE = `import { defineHandler } from 'ubean';
167
+
168
+ export default defineHandler(async c => {
169
+ return c.json({ message: '{{name}} endpoint' });
170
+ });
171
+ `;
172
+ const MIDDLEWARE_TEMPLATE = `import { defineMiddleware } from 'ubean';
173
+
174
+ export default defineMiddleware(async (c, next) => {
175
+ console.log('{{name}} middleware');
176
+ await next();
177
+ });
178
+ `;
179
+ const LAYOUT_TEMPLATE = `<script setup lang="ts">
180
+ definePage({
181
+ meta: {
182
+ title: '{{name}}'
183
+ }
184
+ });
185
+ <\/script>
186
+
187
+ <template>
188
+ <div class="{{kebabName}}">
189
+ <slot />
190
+ </div>
191
+ </template>
192
+ `;
193
+ const CRON_TEMPLATE = `import { defineScheduled } from 'ubean';
194
+
195
+ export default defineScheduled({
196
+ name: '{{name}}',
197
+ schedule: '{{schedule}}'
198
+ }, async () => {
199
+ console.log('Running {{name}} cron task');
200
+ });
201
+ `;
202
+ const PLUGIN_TEMPLATE = `import { definePlugin } from 'ubean';
203
+
204
+ export default definePlugin({
205
+ name: '{{kebabName}}',
206
+ setup() {
207
+ console.log('{{name}} plugin setup');
208
+ }
209
+ });
210
+ `;
211
+ function renderPageTemplate(data) {
212
+ return renderTemplate(PAGE_TEMPLATE, { variables: {
213
+ ...data,
214
+ kebabName: toKebabCase(data.name)
215
+ } });
216
+ }
217
+ function renderApiTemplate(data) {
218
+ return renderTemplate(API_TEMPLATE, { variables: {
219
+ ...data,
220
+ kebabName: toKebabCase(data.name)
221
+ } });
222
+ }
223
+ function renderMiddlewareTemplate(data) {
224
+ return renderTemplate(MIDDLEWARE_TEMPLATE, { variables: data });
225
+ }
226
+ function renderLayoutTemplate(data) {
227
+ const kebabName = toKebabCase(data.name);
228
+ return renderTemplate(LAYOUT_TEMPLATE, { variables: {
229
+ ...data,
230
+ kebabName,
231
+ pascalName: toPascalCase(data.name)
232
+ } });
233
+ }
234
+ function renderCronTemplate(data) {
235
+ return renderTemplate(CRON_TEMPLATE, { variables: {
236
+ ...data,
237
+ kebabName: toKebabCase(data.name)
238
+ } });
239
+ }
240
+ function renderPluginTemplate(data) {
241
+ const kebabName = toKebabCase(data.name);
242
+ return renderTemplate(PLUGIN_TEMPLATE, { variables: {
243
+ ...data,
244
+ kebabName,
245
+ pascalName: toPascalCase(data.name)
246
+ } });
247
+ }
248
+ //#endregion
249
+ //#region src/shared/unify-template.ts
250
+ const PACKAGE_JSON = `{
251
+ "name": "{{name}}",
252
+ "version": "0.0.0",
253
+ "private": true,
254
+ "type": "module",
255
+ "scripts": {
256
+ "build": "ubean build",
257
+ "commit": "soy git-commit",
258
+ "dev": "ubean dev",
259
+ "fmt": "vp fmt",
260
+ "lint": "vp lint --fix",
261
+ "preview": "ubean preview",
262
+ "typecheck": "vue-tsc --noEmit",
263
+ "upkg": "soy ncu"
264
+ },
265
+ "dependencies": {
266
+ "@soybeanjs/fetch": "^1.0.0",
267
+ "ubean": "latest",
268
+ "valibot": "^1.4.2"
269
+ },
270
+ "devDependencies": {
271
+ "@soybeanjs/cli": "^1.8.1",
272
+ "@soybeanjs/eslint-config-vue": "^0.1.1",
273
+ "@soybeanjs/oxc-config": "^0.2.3",
274
+ "eslint": "^10.8.0",
275
+ "typescript": "^5.6.0",
276
+ "vite-plus": "^0.2.6",
277
+ "vue-tsc": "^2.1.0"
278
+ }
279
+ }
280
+ `;
281
+ const PNPM_WORKSPACE = `shamefullyHoist: true
282
+ ignoreWorkspaceRootCheck: true
283
+ linkWorkspacePackages: true
284
+ allowBuilds:
285
+ esbuild: false
286
+ msw: false
287
+ sharp: false
288
+ unrs-resolver: false
289
+ workerd: false
290
+ `;
291
+ const TSCONFIG = `{
292
+ "compilerOptions": {
293
+ "target": "ESNext",
294
+ "lib": ["DOM", "ESNext"],
295
+ "module": "ESNext",
296
+ "moduleResolution": "Bundler",
297
+ "resolveJsonModule": true,
298
+ "paths": {
299
+ "@/*": ["./src/*"]
300
+ },
301
+ "strict": true,
302
+ "strictNullChecks": true,
303
+ "noUnusedLocals": true,
304
+ "allowSyntheticDefaultImports": true,
305
+ "esModuleInterop": true,
306
+ "forceConsistentCasingInFileNames": true,
307
+ "isolatedModules": true
308
+ },
309
+ "exclude": ["node_modules", "dist"]
310
+ }
311
+ `;
312
+ const VITE_CONFIG = `import { defineConfig } from 'vite-plus';
313
+ import { lint, fmt } from '@soybeanjs/oxc-config';
314
+ import { ubeanPlugin } from 'ubean/vite';
315
+
316
+ export default defineConfig({
317
+ staged: {
318
+ '*': 'vp check --fix'
319
+ },
320
+ fmt,
321
+ lint,
322
+ plugins: [ubeanPlugin() as any]
323
+ });
324
+ `;
325
+ const ESLINT_CONFIG = `import { defineConfig } from '@soybeanjs/eslint-config-vue';
326
+
327
+ export default defineConfig();
328
+ `;
329
+ const UBEAN_CONFIG = `import { defineConfig } from 'ubean';
330
+
331
+ export default defineConfig({
332
+ srcDir: 'src',
333
+ preset: '{{preset}}',
334
+ i18n: {
335
+ defaultLocale: 'zh',
336
+ locales: ['en', 'zh'],
337
+ strategy: 'prefix_except_default'
338
+ }
339
+ });
340
+ `;
341
+ const GITIGNORE = `# Logs
342
+ logs
343
+ *.log
344
+ npm-debug.log*
345
+ yarn-debug.log*
346
+ yarn-error.log*
347
+ pnpm-debug.log*
348
+ lerna-debug.log*
349
+
350
+ node_modules
351
+ .DS_Store
352
+ dist
353
+ dist-ssr
354
+ coverage
355
+ *.local
356
+ *.local.yaml
357
+
358
+ # Editor directories and files
359
+ .vscode/*
360
+ !.vscode/extensions.json
361
+ !.vscode/settings.json
362
+ !.vscode/launch.json
363
+ .idea
364
+ *.suo
365
+ *.ntvs*
366
+ *.njsproj
367
+ *.sln
368
+ *.sw?
369
+
370
+ .VSCodeCounter
371
+
372
+ .temp
373
+ .turbo
374
+ .ubean
375
+ `;
376
+ const EDITORCONFIG = `# Editor configuration, see http://editorconfig.org
377
+
378
+ root = true
379
+
380
+ [*]
381
+ charset = utf-8
382
+ indent_style = space
383
+ indent_size = 2
384
+ end_of_line = lf
385
+ trim_trailing_whitespace = true
386
+ insert_final_newline = true
387
+ `;
388
+ const GITATTRIBUTES = `* text=auto
389
+ *.* text eol=lf
390
+ `;
391
+ const VSCODE_SETTINGS = `{
392
+ "editor.codeActionsOnSave": {
393
+ "source.fixAll.eslint": "explicit",
394
+ "source.fixAll.oxc": "explicit"
395
+ },
396
+ "editor.defaultFormatter": "oxc.oxc-vscode",
397
+ "editor.formatOnSave": true,
398
+ "editor.formatOnSaveMode": "file",
399
+ "eslint.validate": ["vue"],
400
+ "oxc.fmt.configPath": "./vite.config.ts",
401
+ "i18n-ally.enabledFrameworks": ["vue"],
402
+ "i18n-ally.sourceLanguage": "zh",
403
+ "i18n-ally.keystyle": "nested",
404
+ "i18n-ally.localesPaths": "src/locales",
405
+ "[vue]": {
406
+ "editor.defaultFormatter": "oxc.oxc-vscode"
407
+ }
408
+ }
409
+ `;
410
+ const README = `# {{name}}
411
+
412
+ A full-stack project powered by ubean.
413
+
414
+ ## Getting Started
415
+
416
+ \`\`\`bash
417
+ # Install dependencies
418
+ {{pm}} install
419
+
420
+ # Start dev server
421
+ {{pm}} dev
422
+
423
+ # Build for production
424
+ {{pm}} build
425
+
426
+ # Preview production build
427
+ {{pm}} preview
428
+ \`\`\`
429
+
430
+ ## Features
431
+
432
+ - Islands Architecture (client:load / idle / visible / media / only)
433
+ - i18n (prefix_except_default strategy, zh/en)
434
+ - Multiple layouts (default / admin)
435
+ - Global & i18n middleware
436
+ - Typed HTTP client (@soybeanjs/fetch + OpenAPI)
437
+ - API routes with OpenAPI validation (valibot)
438
+ - Route guards (beforeEach / afterEach)
439
+ - ESLint + OXC formatting
440
+
441
+ ## Project Structure
442
+
443
+ \`\`\`
444
+ ├── src/
445
+ │ ├── components/ # Vue components (Islands)
446
+ │ ├── layouts/ # Layout components (default / admin)
447
+ │ ├── locales/ # i18n message files (en / zh)
448
+ │ ├── middleware/ # Request middleware (global / i18n)
449
+ │ ├── pages/ # File-based routing (with route groups)
450
+ │ ├── routes/ # API routes
451
+ │ ├── request/ # Typed HTTP client & internal fetch
452
+ │ ├── app.ts # Client app definition (defineApp)
453
+ │ └── server.ts # Server definition (defineServer)
454
+ ├── public/ # Static assets
455
+ ├── ubean.config.ts # ubean configuration
456
+ └── vite.config.ts # Vite + lint/fmt configuration
457
+ \`\`\`
458
+ `;
459
+ const APP_TS = [
460
+ "import { defineApp, hydrateIslands } from 'ubean/runtime/vue';",
461
+ "import IslandClock from './components/IslandClock.vue';",
462
+ "import IslandCounter from './components/IslandCounter.vue';",
463
+ "import IslandMedia from './components/IslandMedia.vue';",
464
+ "import IslandOnly from './components/IslandOnly.vue';",
465
+ "import IslandVisibility from './components/IslandVisibility.vue';",
466
+ "",
467
+ "export default defineApp({",
468
+ " head: {",
469
+ " title: '{{name}}',",
470
+ " meta: [",
471
+ " { name: 'description', content: 'A full-stack project powered by ubean' },",
472
+ " { name: 'viewport', content: 'width=device-width, initial-scale=1' }",
473
+ " ]",
474
+ " },",
475
+ " rootId: 'app',",
476
+ " // 路由钩子示例 — 在 Client 和 SSR 都会执行。",
477
+ " // 守卫必须同步注册(函数体本身可以返回 Promise)。",
478
+ " router: {",
479
+ " setup(router) {",
480
+ " // 全局前置守卫:打印每次导航",
481
+ " router.beforeEach((to, from) => {",
482
+ " // eslint-disable-next-line no-console",
483
+ " console.log(`[router] ${from.fullPath} → ${to.fullPath}`);",
484
+ " });",
485
+ "",
486
+ " // 全局后置钩子:可在此处做埋点",
487
+ " router.afterEach(to => {",
488
+ " // 实际场景:发送到 analytics / 设置页面标题等",
489
+ " if (typeof document !== 'undefined' && to.meta?.title) {",
490
+ " document.title = String(to.meta.title);",
491
+ " }",
492
+ " });",
493
+ " }",
494
+ " },",
495
+ " onClientReady: app => {",
496
+ " hydrateIslands({",
497
+ " components: {",
498
+ " IslandClock,",
499
+ " IslandCounter,",
500
+ " IslandMedia,",
501
+ " IslandOnly,",
502
+ " IslandVisibility",
503
+ " },",
504
+ " appContext: app",
505
+ " });",
506
+ " }",
507
+ "});",
508
+ ""
509
+ ].join("\n");
510
+ const SERVER_TS = [
511
+ "import { defineServer } from 'ubean/runtime/app';",
512
+ "",
513
+ "export default defineServer({",
514
+ " // 运行时钩子",
515
+ " hooks: {",
516
+ " 'request:start': c => {",
517
+ " console.log(`[server] ${c.req.method} ${c.req.path}`);",
518
+ " }",
519
+ " },",
520
+ "",
521
+ " // 在 app.init() 后调用",
522
+ " onServerReady: async _app => {",
523
+ " console.log('[server] Server is ready');",
524
+ " }",
525
+ "});",
526
+ ""
527
+ ].join("\n");
528
+ const DEFAULT_LAYOUT = `<script setup lang="ts"><\/script>
529
+
530
+ <template>
531
+ <div>
532
+ <div>Default Layout</div>
533
+ <PageView></PageView>
534
+ </div>
535
+ </template>
536
+ `;
537
+ const ADMIN_LAYOUT = `<script setup lang="ts"><\/script>
538
+
539
+ <template>
540
+ <div>
541
+ <div>Admin Layout</div>
542
+ <PageView></PageView>
543
+ </div>
544
+ </template>
545
+ `;
546
+ const INDEX_PAGE = `<script setup lang="ts"><\/script>
547
+
548
+ <template>
549
+ <div>Index Page</div>
550
+ </template>
551
+ `;
552
+ const ABOUT_PAGE = `<script setup lang="ts"><\/script>
553
+
554
+ <template>
555
+ <div>About Page</div>
556
+ </template>
557
+ `;
558
+ const ABOUT_REUSE = `import { definePage } from 'ubean';
559
+
560
+ export default definePage({
561
+ reuse: 'About'
562
+ });
563
+ `;
564
+ const DASHBOARD_PAGE = `<script setup lang="ts">
565
+ definePage({
566
+ layout: 'admin'
567
+ });
568
+ <\/script>
569
+
570
+ <template>
571
+ <div>Dashboard</div>
572
+ </template>
573
+ `;
574
+ const GLOBAL_MIDDLEWARE = [
575
+ "import { defineMiddleware } from 'ubean';",
576
+ "",
577
+ "export default defineMiddleware(async (c, next) => {",
578
+ " const start = Date.now();",
579
+ " await next();",
580
+ " const duration = Date.now() - start;",
581
+ " c.header('X-Test-Middleware', 'ubean-test-global');",
582
+ " c.header('X-Response-Time', `${duration}ms`);",
583
+ "});",
584
+ ""
585
+ ].join("\n");
586
+ const I18N_MIDDLEWARE = [
587
+ "import { defineMiddleware, createI18nMiddleware, setI18nConfig } from 'ubean';",
588
+ "",
589
+ "// Set global i18n config (used by localizePath, switchLocalePath, etc.)",
590
+ "// Locale messages are auto-loaded by ubean:locales virtual module.",
591
+ "setI18nConfig({",
592
+ " defaultLocale: 'en',",
593
+ " strategy: 'prefix_except_default',",
594
+ " locales: ['en', 'zh']",
595
+ "});",
596
+ "",
597
+ "// Create i18n middleware instance once.",
598
+ "const i18nHandler = createI18nMiddleware({",
599
+ " strategy: 'prefix_except_default',",
600
+ " defaultLocale: 'en',",
601
+ " locales: ['en', 'zh'],",
602
+ " detectFromHeader: true,",
603
+ " detectFromCookie: true,",
604
+ " redirectOnLocaleMismatch: false",
605
+ "});",
606
+ "",
607
+ "export default defineMiddleware(async (c, next) => {",
608
+ " return i18nHandler(c, next);",
609
+ "});",
610
+ ""
611
+ ].join("\n");
612
+ const HELLO_API = `import { pipe, description, object, string, number } from 'valibot';
613
+ import { defineHandler, describeRoute, resolver } from 'ubean';
614
+
615
+ const helloWorldSchema = object({
616
+ message: pipe(string(), description('The greeting message')),
617
+ status: pipe(number(), description('The status'))
618
+ });
619
+
620
+ export const GET = defineHandler(
621
+ describeRoute({
622
+ summary: 'Hello World',
623
+ description: 'Returns a greeting message.',
624
+ responses: {
625
+ 200: {
626
+ description: 'Successful response',
627
+ content: {
628
+ 'application/json': {
629
+ schema: resolver(helloWorldSchema)
630
+ }
631
+ }
632
+ }
633
+ }
634
+ }),
635
+ c =>
636
+ c.json({
637
+ message: 'Hello, World!',
638
+ status: 200
639
+ })
640
+ );
641
+ `;
642
+ const REQUEST_CLIENT = `import { createRequest } from '@soybeanjs/fetch';
643
+ import { createTypedClient, toFlatTypedClient } from '@soybeanjs/fetch/openapi';
644
+ import type { paths } from '../../.ubean/openapi';
645
+
646
+ const request = createRequest({});
647
+
648
+ export const api = createTypedClient<paths, '/api'>(request, '/api');
649
+
650
+ export const flatApi = toFlatTypedClient<paths, '/api'>(request, '/api');
651
+ `;
652
+ const REQUEST_INTERNAL = `import { createRequest } from '@soybeanjs/fetch';
653
+ import { createTypedClient } from '@soybeanjs/fetch/openapi';
654
+ import { createInternalAdapter } from 'ubean';
655
+ import type { paths } from '../../.ubean/openapi';
656
+
657
+ export function createServerApi(context: Parameters<typeof createInternalAdapter>[0]) {
658
+ const adapter = createInternalAdapter(context);
659
+
660
+ const request = createRequest({
661
+ adapter
662
+ });
663
+
664
+ return createTypedClient<paths, '/api'>(request, '/api');
665
+ }
666
+ `;
667
+ const ISLAND_COUNTER = `<script setup lang="ts">
668
+ import { ref, onMounted } from 'vue';
669
+
670
+ const count = ref(0);
671
+ const mountedAt = ref<string>('');
672
+
673
+ function increment() {
674
+ count.value++;
675
+ }
676
+
677
+ onMounted(() => {
678
+ mountedAt.value = new Date().toISOString();
679
+ });
680
+ <\/script>
681
+
682
+ <template>
683
+ <div class="island-counter">
684
+ <p class="island-label">client:load Island</p>
685
+ <p class="counter-value">{{ count }}</p>
686
+ <button @click="increment">+1</button>
687
+ <p v-if="mountedAt" class="mounted-info">Mounted at: {{ mountedAt }}</p>
688
+ </div>
689
+ </template>
690
+
691
+ <style scoped>
692
+ .island-counter {
693
+ padding: 1rem;
694
+ border: 2px solid #42b883;
695
+ border-radius: 8px;
696
+ background: #f0fdf4;
697
+ }
698
+
699
+ .island-label {
700
+ font-size: 0.8rem;
701
+ color: #15803d;
702
+ font-weight: 600;
703
+ margin-bottom: 0.5rem;
704
+ }
705
+
706
+ .counter-value {
707
+ font-size: 2rem;
708
+ font-weight: 700;
709
+ color: #166534;
710
+ }
711
+
712
+ button {
713
+ padding: 0.4rem 1rem;
714
+ background: #42b883;
715
+ color: white;
716
+ border: none;
717
+ border-radius: 4px;
718
+ cursor: pointer;
719
+ font-size: 0.9rem;
720
+ }
721
+
722
+ button:hover {
723
+ background: #35495e;
724
+ }
725
+
726
+ .mounted-info {
727
+ margin-top: 0.5rem;
728
+ font-size: 0.75rem;
729
+ color: #6b7280;
730
+ font-family: monospace;
731
+ }
732
+ </style>
733
+ `;
734
+ const ISLAND_CLOCK = `<script setup lang="ts">
735
+ import { ref, onMounted, onUnmounted } from 'vue';
736
+
737
+ const now = ref<string>('--:--:--');
738
+ let timer: ReturnType<typeof setInterval> | null = null;
739
+
740
+ function update() {
741
+ now.value = new Date().toLocaleTimeString('zh-CN', { hour12: false });
742
+ }
743
+
744
+ onMounted(() => {
745
+ update();
746
+ timer = setInterval(update, 1000);
747
+ });
748
+
749
+ onUnmounted(() => {
750
+ if (timer) clearInterval(timer);
751
+ });
752
+ <\/script>
753
+
754
+ <template>
755
+ <div class="island-clock">
756
+ <p class="island-label">client:idle Island</p>
757
+ <p class="clock-time">{{ now }}</p>
758
+ <p class="clock-hint">Hydrated when browser is idle</p>
759
+ </div>
760
+ </template>
761
+
762
+ <style scoped>
763
+ .island-clock {
764
+ padding: 1rem;
765
+ border: 2px solid #3b82f6;
766
+ border-radius: 8px;
767
+ background: #eff6ff;
768
+ }
769
+
770
+ .island-label {
771
+ font-size: 0.8rem;
772
+ color: #1d4ed8;
773
+ font-weight: 600;
774
+ margin-bottom: 0.5rem;
775
+ }
776
+
777
+ .clock-time {
778
+ font-size: 1.8rem;
779
+ font-weight: 700;
780
+ color: #1e3a8a;
781
+ font-family: 'SF Mono', 'Fira Code', monospace;
782
+ letter-spacing: 2px;
783
+ }
784
+
785
+ .clock-hint {
786
+ margin-top: 0.5rem;
787
+ font-size: 0.75rem;
788
+ color: #6b7280;
789
+ }
790
+ </style>
791
+ `;
792
+ const ISLAND_MEDIA = `<script setup lang="ts">
793
+ import { ref, onMounted, onUnmounted } from 'vue';
794
+
795
+ const isWide = ref(false);
796
+ const checks = ref(0);
797
+ let mql: MediaQueryList | null = null;
798
+
799
+ function onChange(e: MediaQueryListEvent) {
800
+ isWide.value = e.matches;
801
+ checks.value++;
802
+ }
803
+
804
+ onMounted(() => {
805
+ mql = window.matchMedia('(min-width: 768px)');
806
+ isWide.value = mql.matches;
807
+ mql.addEventListener('change', onChange);
808
+ });
809
+
810
+ onUnmounted(() => {
811
+ if (mql) mql.removeEventListener('change', onChange);
812
+ });
813
+ <\/script>
814
+
815
+ <template>
816
+ <div class="island-media">
817
+ <p class="island-label">client:media Island</p>
818
+ <p class="media-status" :class="{ wide: isWide, narrow: !isWide }">
819
+ {{ isWide ? '🖥 Wide screen (≥768px)' : '📱 Narrow screen (<768px)' }}
820
+ </p>
821
+ <p class="media-info">Media query changes: {{ checks }}</p>
822
+ <p class="media-hint">Resize browser to trigger media query changes</p>
823
+ </div>
824
+ </template>
825
+
826
+ <style scoped>
827
+ .island-media {
828
+ padding: 1rem;
829
+ border: 2px solid #8b5cf6;
830
+ border-radius: 8px;
831
+ background: #f5f3ff;
832
+ }
833
+
834
+ .island-label {
835
+ font-size: 0.8rem;
836
+ color: #6d28d9;
837
+ font-weight: 600;
838
+ margin-bottom: 0.5rem;
839
+ }
840
+
841
+ .media-status {
842
+ font-size: 1.1rem;
843
+ font-weight: 600;
844
+ }
845
+
846
+ .media-status.wide {
847
+ color: #166534;
848
+ }
849
+
850
+ .media-status.narrow {
851
+ color: #92400e;
852
+ }
853
+
854
+ .media-info,
855
+ .media-hint {
856
+ margin-top: 0.3rem;
857
+ font-size: 0.75rem;
858
+ color: #6b7280;
859
+ }
860
+ </style>
861
+ `;
862
+ const ISLAND_ONLY = `<script setup lang="ts">
863
+ import { ref, onMounted } from 'vue';
864
+
865
+ const clientTime = ref<string>('');
866
+
867
+ onMounted(() => {
868
+ clientTime.value = new Date().toISOString();
869
+ });
870
+ <\/script>
871
+
872
+ <template>
873
+ <div class="island-only">
874
+ <p class="island-label">client:only Island</p>
875
+ <p class="only-status">✓ Rendered on client only</p>
876
+ <p v-if="clientTime" class="only-time">Client time: {{ clientTime }}</p>
877
+ <p class="only-hint">This component was NOT server-side rendered</p>
878
+ </div>
879
+ </template>
880
+
881
+ <style scoped>
882
+ .island-only {
883
+ padding: 1rem;
884
+ border: 2px dashed #ec4899;
885
+ border-radius: 8px;
886
+ background: #fdf2f8;
887
+ }
888
+
889
+ .island-label {
890
+ font-size: 0.8rem;
891
+ color: #be185d;
892
+ font-weight: 600;
893
+ margin-bottom: 0.5rem;
894
+ }
895
+
896
+ .only-status {
897
+ font-size: 1.1rem;
898
+ font-weight: 600;
899
+ color: #166534;
900
+ }
901
+
902
+ .only-time {
903
+ margin-top: 0.3rem;
904
+ font-size: 0.75rem;
905
+ color: #6b7280;
906
+ font-family: monospace;
907
+ }
908
+
909
+ .only-hint {
910
+ margin-top: 0.3rem;
911
+ font-size: 0.75rem;
912
+ color: #9ca3af;
913
+ font-style: italic;
914
+ }
915
+ </style>
916
+ `;
917
+ const ISLAND_VISIBILITY = `<script setup lang="ts">
918
+ import { ref, onMounted } from 'vue';
919
+
920
+ const visible = ref(false);
921
+ const visibleCount = ref(0);
922
+ const firstVisibleAt = ref<string>('');
923
+
924
+ onMounted(() => {
925
+ // The IntersectionObserver is set up by the bootstrap script;
926
+ // once the island is hydrated, we mark it as visible.
927
+ visible.value = true;
928
+ visibleCount.value = 1;
929
+ firstVisibleAt.value = new Date().toISOString();
930
+ });
931
+ <\/script>
932
+
933
+ <template>
934
+ <div class="island-visibility">
935
+ <p class="island-label">client:visible Island</p>
936
+ <p class="vis-status" :class="{ active: visible }">
937
+ {{ visible ? '✓ Visible & Hydrated' : '○ Waiting for visibility...' }}
938
+ </p>
939
+ <p v-if="firstVisibleAt" class="vis-time">First visible: {{ firstVisibleAt }}</p>
940
+ <p class="vis-count">Visibility events: {{ visibleCount }}</p>
941
+ </div>
942
+ </template>
943
+
944
+ <style scoped>
945
+ .island-visibility {
946
+ padding: 1rem;
947
+ border: 2px solid #f59e0b;
948
+ border-radius: 8px;
949
+ background: #fffbeb;
950
+ }
951
+
952
+ .island-label {
953
+ font-size: 0.8rem;
954
+ color: #b45309;
955
+ font-weight: 600;
956
+ margin-bottom: 0.5rem;
957
+ }
958
+
959
+ .vis-status {
960
+ font-size: 1.1rem;
961
+ font-weight: 600;
962
+ color: #92400e;
963
+ }
964
+
965
+ .vis-status.active {
966
+ color: #166534;
967
+ }
968
+
969
+ .vis-time,
970
+ .vis-count {
971
+ margin-top: 0.3rem;
972
+ font-size: 0.75rem;
973
+ color: #6b7280;
974
+ font-family: monospace;
975
+ }
976
+ </style>
977
+ `;
978
+ const EN_LOCALE = `{
979
+ "app": {
980
+ "title": "{{name}}",
981
+ "description": "A full-stack project powered by ubean"
982
+ }
983
+ }
984
+ `;
985
+ const ZH_LOCALE = `{
986
+ "app": {
987
+ "title": "{{name}}",
988
+ "description": "基于 ubean 框架的全栈项目"
989
+ }
990
+ }
991
+ `;
992
+ /** ubean logo SVG — 所有模板共用 */
993
+ const FAVICON_SVG = `<svg width="100%" height="100%" version="1.1" viewBox="0 0 1000 1000" xmlns="http://www.w3.org/2000/svg"
994
+ xmlns:xlink="http://www.w3.org/1999/xlink">
995
+ <g>
996
+ <path d="M 200,866 C 100,866 50,779.4 100,692.8 L 200,519.6 C 220,485 240,490 265,499.6 S 360,542.68 360,542.68 C 480.5,601 498,642.5 500,720 C 498,811 462,856 420,866" fill="url(#LinearGradient)" fill-rule="nonzero" opacity="1" stroke="none" />
997
+ <path d="M 420,866 C 455,861 478,846 500,827 C 614,696 615,597 500,517 C 394,444 333,374 380,207.82 L 260,415.67 C 240.22,450 254.37,465.1 275.28,481.79 S 360,542.68 360,542.68 C 480.5,601 498,642.5 500,720 C 498,811 462,856 420,866" fill="url(#LinearGradient_2)" fill-rule="nonzero" opacity="1" stroke="none" />
998
+ <path d="M 500,517 C 394,444 333,374 380,207.82 L 400,173.2 C 367,295 421,350 603,428 C 572,440 524,474 500,517" fill="url(#LinearGradient_3)" fill-rule="nonzero" opacity="1" stroke="none" />
999
+ <path d="M 500,827 L 660,660 C 738,589 710,482 603,428 C 572,440 524,474 500,517 C 615,597 614,696 500,827" fill="url(#LinearGradient_4)" fill-rule="nonzero" opacity="1" stroke="none" />
1000
+ <path d="M 400,173.2 C 367,295 421,350 603,428 C 690,389, 750,445 788,500 L 600,173.2 C 550,86.6 450,86.6 400,173.2" fill="url(#LinearGradient_5)" fill-rule="nonzero" opacity="1" stroke="none" />
1001
+ <path d="M 500,827 L 660,660 C 738,589 710,482 603,428 C 690,389, 750,445 788,500 C 816,554 797,606 750,640 L 500,827" fill="url(#LinearGradient_6)" fill-rule="nonzero" opacity="1" stroke="none" />
1002
+ <path d="M 788,500 C 816,554 797,606 750,640 L 500,827 C 497,851 513,862 540,866 L 800,866 C 900,866 950,779.4 900,692.8 L 788,500" fill="url(#LinearGradient_7)" fill-rule="nonzero" opacity="1" stroke="none" />
1003
+ <g transform="translate(140, 650) scale(0.18)">
1004
+ <path d="M539.04,146.5c-77.37,32.74-126.47,114.53-123.22,198,.61,150.92,169.47,210.6,214.99,341.13,39.71,96.62-16.62,230.68-122.31,253.16-374.89-83.15-390.44-785.49,30.54-792.29ZM564.61,948.5c68.1-32.18,118.51-95.45,132.49-169.97,41.81-214.6-158.22-254.7-215.15-413.02-31.14-86.21,21.33-198.66,114.56-214.57,129.21,21.58,214.93,148.7,230.96,272.19,7.94,38.53,21.57,75.37,34.41,112.43,75.75,198.26-76.06,438.99-297.26,412.94Z" fill="#ffffff" fill-rule="evenodd"/>
1005
+ </g>
1006
+ </g>
1007
+ <defs>
1008
+ <linearGradient gradientTransform="matrix(104.391 -73.3432 73.3432 104.391 277.441 710.122)" gradientUnits="userSpaceOnUse" id="LinearGradient" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#373ebf" /><stop offset="1" stop-color="#5058e6" /></linearGradient>
1009
+ <linearGradient gradientTransform="matrix(-173.747 557.324 -557.324 -173.747 508.829 258.172)" gradientUnits="userSpaceOnUse" id="LinearGradient_2" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#c2d6ff" /><stop offset="1" stop-color="#646cff" /></linearGradient>
1010
+ <linearGradient gradientTransform="matrix(157.951 295.666 -295.666 157.951 382.944 193.642)" gradientUnits="userSpaceOnUse" id="LinearGradient_3" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#5058e6" /><stop offset="1" stop-color="#373ebf" /></linearGradient>
1011
+ <linearGradient gradientTransform="matrix(-44.3023 219.578 -219.578 -44.3023 619.69 469.652)" gradientUnits="userSpaceOnUse" id="LinearGradient_4" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#91a7ff" /><stop offset="1" stop-color="#5058e6" /></linearGradient>
1012
+ <linearGradient gradientTransform="matrix(125.52 334.256 -334.256 125.52 539.723 235.139)" gradientUnits="userSpaceOnUse" id="LinearGradient_5" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#646cff" /><stop offset="1" stop-color="#c2d6ff" /></linearGradient>
1013
+ <linearGradient gradientTransform="matrix(-241.23 357.206 -357.206 -241.23 754.054 449.312)" gradientUnits="userSpaceOnUse" id="LinearGradient_6" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#c2d6ff" /><stop offset="1" stop-color="#646cff" /></linearGradient>
1014
+ <linearGradient gradientTransform="matrix(125.978 210.065 -210.065 125.978 596.433 613.665)" gradientUnits="userSpaceOnUse" id="LinearGradient_7" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#373ebf" /><stop offset="1" stop-color="#5058e6" /></linearGradient>
1015
+ </defs>
1016
+ </svg>
1017
+ `;
1018
+ /** 简化版 app.ts — 无 Islands / 无路由守卫 */
1019
+ const APP_TS_BASE = `import { defineApp } from 'ubean/runtime/vue';
1020
+
1021
+ export default defineApp({
1022
+ head: {
1023
+ title: '{{name}}',
1024
+ meta: [
1025
+ { name: 'description', content: 'A project powered by ubean' },
1026
+ { name: 'viewport', content: 'width=device-width, initial-scale=1' }
1027
+ ]
1028
+ },
1029
+ rootId: 'app'
1030
+ });
1031
+ `;
1032
+ /** 简化版 ubean.config.ts — 无 i18n */
1033
+ const UBEAN_CONFIG_BASE = `import { defineConfig } from 'ubean';
1034
+
1035
+ export default defineConfig({
1036
+ srcDir: 'src',
1037
+ preset: '{{preset}}'
1038
+ });
1039
+ `;
1040
+ /** 基础 README — minimal/starter/blog 共用 */
1041
+ const README_BASE = `# {{name}}
1042
+
1043
+ A project powered by ubean.
1044
+
1045
+ ## Getting Started
1046
+
1047
+ \`\`\`bash
1048
+ {{pm}} install
1049
+ {{pm}} dev
1050
+ {{pm}} build
1051
+ {{pm}} preview
1052
+ \`\`\`
1053
+
1054
+ ## Project Structure
1055
+
1056
+ \`\`\`
1057
+ ├── src/
1058
+ │ ├── pages/ # File-based routing
1059
+ │ ├── layouts/ # Layout components
1060
+ │ ├── routes/ # API routes
1061
+ │ ├── components/ # Vue components
1062
+ │ ├── request/ # Typed HTTP client & internal fetch
1063
+ │ ├── app.ts # Client app definition (defineApp)
1064
+ │ └── server.ts # Server definition (defineServer)
1065
+ ├── public/ # Static assets
1066
+ └── ubean.config.ts # ubean configuration
1067
+ \`\`\`
1068
+ `;
1069
+ const MINIMAL_INDEX_PAGE = `<script setup lang="ts"><\/script>
1070
+
1071
+ <template>
1072
+ <div>
1073
+ <h1>Welcome to ubean</h1>
1074
+ <p>Your Vue meta framework is ready!</p>
1075
+ </div>
1076
+ </template>
1077
+ `;
1078
+ const STARTER_INDEX_PAGE = `<script setup lang="ts"><\/script>
1079
+
1080
+ <template>
1081
+ <div>
1082
+ <h1>Welcome to ubean</h1>
1083
+ <p>Your Vue meta framework is ready!</p>
1084
+ <Link to="/about">About</Link>
1085
+ </div>
1086
+ </template>
1087
+ `;
1088
+ const STARTER_ABOUT_PAGE = `<script setup lang="ts"><\/script>
1089
+
1090
+ <template>
1091
+ <div>
1092
+ <h1>About</h1>
1093
+ <p>This page is built with ubean.</p>
1094
+ <Link to="/">Back to Home</Link>
1095
+ </div>
1096
+ </template>
1097
+ `;
1098
+ const BLOG_INDEX_PAGE = `<script setup lang="ts">
1099
+ import { useData } from 'ubean';
1100
+
1101
+ definePage({
1102
+ meta: {
1103
+ title: 'Blog'
1104
+ }
1105
+ });
1106
+
1107
+ const { data: posts } = await useData('posts', () => {
1108
+ return [
1109
+ { slug: 'hello-world', title: 'Hello World', date: '2024-01-15', excerpt: 'Welcome to my blog!' }
1110
+ ];
1111
+ });
1112
+ <\/script>
1113
+
1114
+ <template>
1115
+ <div>
1116
+ <h1>Blog</h1>
1117
+ <div>
1118
+ <article v-for="post in posts" :key="post.slug">
1119
+ <h2><Link :to="'/blog/' + post.slug">{{ post.title }}</Link></h2>
1120
+ <p class="date">{{ post.date }}</p>
1121
+ <p class="excerpt">{{ post.excerpt }}</p>
1122
+ </article>
1123
+ </div>
1124
+ </div>
1125
+ </template>
1126
+ `;
1127
+ const BLOG_POST_MD = `---
1128
+ title: Hello World
1129
+ date: 2024-01-15
1130
+ description: Welcome to my ubean blog!
1131
+ ---
1132
+
1133
+ # Hello World
1134
+
1135
+ Welcome to my new blog built with **ubean**!
1136
+
1137
+ This is a markdown blog post.
1138
+
1139
+ ## Features
1140
+
1141
+ - File-based routing
1142
+ - Markdown support
1143
+ - Auto-imports
1144
+ - API routes
1145
+ - And much more!
1146
+ `;
1147
+ /**
1148
+ * 写入所有模板共用的基础文件(配置 + 入口 + 布局 + 请求客户端)。
1149
+ * 不写入 ubean.config.ts / app.ts / README.md / pages — 由各模板自行补充。
1150
+ */
1151
+ async function scaffoldBase(fs, options) {
1152
+ const { name } = options;
1153
+ await fs.ensureDir("src");
1154
+ await fs.ensureDir("src/pages");
1155
+ await fs.ensureDir("src/layouts");
1156
+ await fs.ensureDir("src/routes");
1157
+ await fs.ensureDir("src/components");
1158
+ await fs.ensureDir("src/request");
1159
+ await fs.ensureDir("public");
1160
+ await fs.ensureDir(".vscode");
1161
+ await fs.writeFile("package.json", renderTemplate(PACKAGE_JSON, { variables: { name } }));
1162
+ await fs.writeFile("pnpm-workspace.yaml", PNPM_WORKSPACE);
1163
+ await fs.writeFile("tsconfig.json", TSCONFIG);
1164
+ await fs.writeFile("vite.config.ts", VITE_CONFIG);
1165
+ await fs.writeFile("eslint.config.mjs", ESLINT_CONFIG);
1166
+ await fs.writeFile(".gitignore", GITIGNORE);
1167
+ await fs.writeFile(".editorconfig", EDITORCONFIG);
1168
+ await fs.writeFile(".gitattributes", GITATTRIBUTES);
1169
+ await fs.writeFile(".vscode/settings.json", VSCODE_SETTINGS);
1170
+ await fs.writeFile("public/favicon.svg", FAVICON_SVG);
1171
+ await fs.writeFile("src/server.ts", SERVER_TS);
1172
+ await fs.writeFile("src/layouts/default.vue", DEFAULT_LAYOUT);
1173
+ await fs.writeFile("src/request/client.ts", REQUEST_CLIENT);
1174
+ await fs.writeFile("src/request/internal.ts", REQUEST_INTERNAL);
1175
+ }
1176
+ /**
1177
+ * 生成完整的 unify 模板项目。
1178
+ *
1179
+ * @param fs 文件系统操作实例(已绑定到目标目录)
1180
+ * @param options 模板变量(name / preset / packageManager)
1181
+ */
1182
+ async function scaffoldUnifyTemplate(fs, options) {
1183
+ const { name, preset, packageManager: pm } = options;
1184
+ await scaffoldBase(fs, options);
1185
+ await fs.ensureDir("src/locales");
1186
+ await fs.ensureDir("src/middleware");
1187
+ await fs.ensureDir("src/pages/(admin)");
1188
+ await fs.ensureDir("src/routes/api");
1189
+ await fs.writeFile("ubean.config.ts", renderTemplate(UBEAN_CONFIG, { variables: { preset } }));
1190
+ await fs.writeFile("README.md", renderTemplate(README, { variables: {
1191
+ name,
1192
+ pm
1193
+ } }));
1194
+ await fs.writeFile("src/app.ts", renderTemplate(APP_TS, { variables: { name } }));
1195
+ await fs.writeFile("src/layouts/admin.vue", ADMIN_LAYOUT);
1196
+ await fs.writeFile("src/pages/index.vue", INDEX_PAGE);
1197
+ await fs.writeFile("src/pages/about.vue", ABOUT_PAGE);
1198
+ await fs.writeFile("src/pages/about2.reuse.ts", ABOUT_REUSE);
1199
+ await fs.writeFile("src/pages/(admin)/dashboard.vue", DASHBOARD_PAGE);
1200
+ await fs.writeFile("src/middleware/01.global.ts", GLOBAL_MIDDLEWARE);
1201
+ await fs.writeFile("src/middleware/02.i18n.ts", I18N_MIDDLEWARE);
1202
+ await fs.writeFile("src/routes/api/hello-world.ts", HELLO_API);
1203
+ await fs.writeFile("src/components/IslandCounter.vue", ISLAND_COUNTER);
1204
+ await fs.writeFile("src/components/IslandClock.vue", ISLAND_CLOCK);
1205
+ await fs.writeFile("src/components/IslandMedia.vue", ISLAND_MEDIA);
1206
+ await fs.writeFile("src/components/IslandOnly.vue", ISLAND_ONLY);
1207
+ await fs.writeFile("src/components/IslandVisibility.vue", ISLAND_VISIBILITY);
1208
+ await fs.writeFile("src/locales/en.json", renderTemplate(EN_LOCALE, { variables: { name } }));
1209
+ await fs.writeFile("src/locales/zh.json", renderTemplate(ZH_LOCALE, { variables: { name } }));
1210
+ }
1211
+ /** Minimal 模板 — 仅首页,无 API/i18n/Islands */
1212
+ async function scaffoldMinimalTemplate(fs, options) {
1213
+ const { name, preset, packageManager: pm } = options;
1214
+ await scaffoldBase(fs, options);
1215
+ await fs.writeFile("ubean.config.ts", renderTemplate(UBEAN_CONFIG_BASE, { variables: { preset } }));
1216
+ await fs.writeFile("README.md", renderTemplate(README_BASE, { variables: {
1217
+ name,
1218
+ pm
1219
+ } }));
1220
+ await fs.writeFile("src/app.ts", renderTemplate(APP_TS_BASE, { variables: { name } }));
1221
+ await fs.writeFile("src/pages/index.vue", MINIMAL_INDEX_PAGE);
1222
+ }
1223
+ /** Starter 模板 — 首页 + About + API 路由 */
1224
+ async function scaffoldStarterTemplate(fs, options) {
1225
+ const { name, preset, packageManager: pm } = options;
1226
+ await scaffoldBase(fs, options);
1227
+ await fs.ensureDir("src/routes/api");
1228
+ await fs.writeFile("ubean.config.ts", renderTemplate(UBEAN_CONFIG_BASE, { variables: { preset } }));
1229
+ await fs.writeFile("README.md", renderTemplate(README_BASE, { variables: {
1230
+ name,
1231
+ pm
1232
+ } }));
1233
+ await fs.writeFile("src/app.ts", renderTemplate(APP_TS_BASE, { variables: { name } }));
1234
+ await fs.writeFile("src/pages/index.vue", STARTER_INDEX_PAGE);
1235
+ await fs.writeFile("src/pages/about.vue", STARTER_ABOUT_PAGE);
1236
+ await fs.writeFile("src/routes/api/hello-world.ts", HELLO_API);
1237
+ }
1238
+ /** Blog 模板 — 首页 + About + Markdown 博客 + API 路由 */
1239
+ async function scaffoldBlogTemplate(fs, options) {
1240
+ const { name, preset, packageManager: pm } = options;
1241
+ await scaffoldBase(fs, options);
1242
+ await fs.ensureDir("src/routes/api");
1243
+ await fs.ensureDir("src/pages/blog");
1244
+ await fs.writeFile("ubean.config.ts", renderTemplate(UBEAN_CONFIG_BASE, { variables: { preset } }));
1245
+ await fs.writeFile("README.md", renderTemplate(README_BASE, { variables: {
1246
+ name,
1247
+ pm
1248
+ } }));
1249
+ await fs.writeFile("src/app.ts", renderTemplate(APP_TS_BASE, { variables: { name } }));
1250
+ await fs.writeFile("src/pages/index.vue", STARTER_INDEX_PAGE);
1251
+ await fs.writeFile("src/pages/about.vue", STARTER_ABOUT_PAGE);
1252
+ await fs.writeFile("src/pages/blog/index.vue", BLOG_INDEX_PAGE);
1253
+ await fs.writeFile("src/pages/blog/hello-world.md", BLOG_POST_MD);
1254
+ await fs.writeFile("src/routes/api/hello-world.ts", HELLO_API);
1255
+ }
1256
+ //#endregion
1257
+ //#region src/page.ts
1258
+ const logger = consola.withTag("ubean-cli");
1259
+ function getSrcDir(cwd) {
1260
+ return join(cwd, "src");
1261
+ }
1262
+ function getDirForType(cwd, type) {
1263
+ const srcDir = getSrcDir(cwd);
1264
+ switch (type) {
1265
+ case "page":
1266
+ case "reuse": return join(srcDir, "pages");
1267
+ case "api": return join(srcDir, "routes");
1268
+ case "layout": return join(srcDir, "layouts");
1269
+ case "middleware": return join(srcDir, "middleware");
1270
+ case "cron": return join(srcDir, "server", "crons");
1271
+ case "plugin": return join(srcDir, "plugins");
1272
+ default: return srcDir;
1273
+ }
1274
+ }
1275
+ /** Resolve a baseDir that may be relative or absolute into an absolute path. */
1276
+ function resolveBaseDir(cwd, baseDir) {
1277
+ return baseDir.startsWith("/") ? baseDir : join(cwd, baseDir);
1278
+ }
1279
+ function resolveTargetPath(baseDir, path, type) {
1280
+ let normalizedPath = path.startsWith("/") ? path.slice(1) : path;
1281
+ if (normalizedPath === "" || normalizedPath === "/") normalizedPath = type === "page" || type === "reuse" ? "index" : "index";
1282
+ if (type === "page" || type === "layout" || type === "reuse") {
1283
+ if (type === "page" || type === "layout") {
1284
+ if (normalizedPath.endsWith(".vue")) return join(baseDir, normalizedPath);
1285
+ const ext = ".vue";
1286
+ if (normalizedPath.endsWith("/") || normalizedPath === "") return join(baseDir, normalizedPath, `index${ext}`);
1287
+ return join(baseDir, `${normalizedPath}${ext}`);
1288
+ }
1289
+ if (normalizedPath.endsWith(".reuse.ts")) return join(baseDir, normalizedPath);
1290
+ if (normalizedPath.endsWith(".ts")) return join(baseDir, normalizedPath.replace(/\.ts$/, ".reuse.ts"));
1291
+ const ext = ".reuse.ts";
1292
+ if (normalizedPath.endsWith("/") || normalizedPath === "") return join(baseDir, normalizedPath, `index${ext}`);
1293
+ return join(baseDir, `${normalizedPath}${ext}`);
1294
+ }
1295
+ if (normalizedPath.endsWith(".ts") || normalizedPath.endsWith(".js")) return join(baseDir, normalizedPath);
1296
+ const ext = ".ts";
1297
+ if (normalizedPath.endsWith("/") || normalizedPath === "") return join(baseDir, normalizedPath, `index${ext}`);
1298
+ if ((type === "api" || type === "middleware" || type === "cron" || type === "plugin") && !normalizedPath.includes(".")) return join(baseDir, `${normalizedPath}${ext}`);
1299
+ return join(baseDir, normalizedPath);
1300
+ }
1301
+ function sanitizeNameSegment(seg) {
1302
+ return seg.replace(/^\[\.\.\.(.+)\]$/, "All$1").replace(/^\[\[(.+)\]\]$/, "$1Optional").replace(/^\[(.+)\]$/, "$1").replace(/[^a-zA-Z0-9$_]/g, "");
1303
+ }
1304
+ function extractNameFromPath(filePath, _type) {
1305
+ const parts = filePath.split("/");
1306
+ const basename = parts.pop() || "";
1307
+ let nameWithoutExt = basename.replace(extname(basename), "");
1308
+ if (nameWithoutExt.endsWith(".reuse")) nameWithoutExt = nameWithoutExt.slice(0, -6);
1309
+ const segments = [];
1310
+ for (const part of parts) {
1311
+ const cleaned = sanitizeNameSegment(part);
1312
+ if (cleaned) segments.push(toPascalCase(cleaned));
1313
+ }
1314
+ if (nameWithoutExt !== "index") segments.push(toPascalCase(sanitizeNameSegment(nameWithoutExt)));
1315
+ else if (segments.length === 0) return "Index";
1316
+ return segments.join("") || "Index";
1317
+ }
1318
+ function getTemplateContent(type, name, path, method, schedule) {
1319
+ switch (type) {
1320
+ case "page": return renderPageTemplate({
1321
+ name,
1322
+ path: `/${path.replace(/\.vue$/, "").replace(/\/index$/, "")}`,
1323
+ kebabName: toKebabCase(name),
1324
+ pascalName: name,
1325
+ camelName: toCamelCase(name)
1326
+ });
1327
+ case "reuse": return `import { definePage } from 'ubean';
1328
+
1329
+ export default definePage({
1330
+ name: '${name}'
1331
+ });
1332
+ `;
1333
+ case "api": return renderApiTemplate({
1334
+ name,
1335
+ method: method || "GET",
1336
+ path: `/api/${path.replace(/\.ts$/, "").replace(/\/index$/, "")}`,
1337
+ kebabName: toKebabCase(name)
1338
+ });
1339
+ case "layout": return renderLayoutTemplate({
1340
+ name,
1341
+ path: `/${path.replace(/\.vue$/, "").replace(/\/index$/, "")}`,
1342
+ pascalName: toPascalCase(name)
1343
+ });
1344
+ case "middleware": return `import { defineMiddleware } from 'ubean';
1345
+
1346
+ export default defineMiddleware(async (c, next) => {
1347
+ console.log('${name} middleware');
1348
+ await next();
1349
+ });
1350
+ `;
1351
+ case "cron": return renderCronTemplate({
1352
+ name,
1353
+ schedule: schedule || "* * * * *",
1354
+ kebabName: toKebabCase(name)
1355
+ });
1356
+ case "plugin": return renderPluginTemplate({
1357
+ name,
1358
+ kebabName: toKebabCase(name),
1359
+ pascalName: toPascalCase(name)
1360
+ });
1361
+ default: return "";
1362
+ }
1363
+ }
1364
+ async function scaffold(options) {
1365
+ const cwd = options.cwd || process.cwd();
1366
+ const fs = createFsOps(cwd);
1367
+ const result = {
1368
+ created: [],
1369
+ deleted: [],
1370
+ restored: [],
1371
+ skipped: [],
1372
+ errors: []
1373
+ };
1374
+ try {
1375
+ const relativePath = resolveTargetPath(options.baseDir ? resolveBaseDir(cwd, options.baseDir) : getDirForType(cwd, options.type), options.path, options.type).replace(`${cwd}/`, "");
1376
+ if (await fs.exists(relativePath)) {
1377
+ if (!options.force) {
1378
+ result.skipped.push(relativePath);
1379
+ return result;
1380
+ }
1381
+ await fs.createBackup(relativePath);
1382
+ }
1383
+ if (options.dry) {
1384
+ result.created.push(relativePath);
1385
+ return result;
1386
+ }
1387
+ const name = extractNameFromPath(relativePath, options.type);
1388
+ const content = getTemplateContent(options.type, name, options.path, options.method, options.schedule);
1389
+ await fs.writeFile(relativePath, content);
1390
+ result.created.push(relativePath);
1391
+ } catch (err) {
1392
+ result.errors.push(err instanceof Error ? err.message : String(err));
1393
+ }
1394
+ return result;
1395
+ }
1396
+ async function deleteScaffold(options) {
1397
+ const cwd = options.cwd || process.cwd();
1398
+ const fs = createFsOps(cwd);
1399
+ const result = {
1400
+ created: [],
1401
+ deleted: [],
1402
+ restored: [],
1403
+ skipped: [],
1404
+ errors: []
1405
+ };
1406
+ try {
1407
+ const relativePath = resolveTargetPath(options.baseDir ? resolveBaseDir(cwd, options.baseDir) : getDirForType(cwd, options.type), options.path, options.type).replace(`${cwd}/`, "");
1408
+ if (!await fs.exists(relativePath)) {
1409
+ result.errors.push(`${relativePath} does not exist`);
1410
+ return result;
1411
+ }
1412
+ if (options.dry) {
1413
+ result.deleted.push(relativePath);
1414
+ return result;
1415
+ }
1416
+ if (options.force) {
1417
+ await fs.remove(relativePath);
1418
+ result.deleted.push(relativePath);
1419
+ return result;
1420
+ }
1421
+ await fs.createBackup(relativePath, { removeOriginal: true });
1422
+ result.deleted.push(relativePath);
1423
+ } catch (err) {
1424
+ result.errors.push(err instanceof Error ? err.message : String(err));
1425
+ }
1426
+ return result;
1427
+ }
1428
+ async function recoverScaffold(options) {
1429
+ const cwd = options.cwd || process.cwd();
1430
+ const fs = createFsOps(cwd);
1431
+ const result = {
1432
+ created: [],
1433
+ deleted: [],
1434
+ restored: [],
1435
+ skipped: [],
1436
+ errors: []
1437
+ };
1438
+ try {
1439
+ const relativePath = resolveTargetPath(options.baseDir ? resolveBaseDir(cwd, options.baseDir) : getDirForType(cwd, options.type), options.path, options.type).replace(`${cwd}/`, "");
1440
+ if (options.dry) {
1441
+ const backupPath = `${relativePath}.bak`;
1442
+ if (await fs.exists(backupPath)) result.restored.push(relativePath);
1443
+ else result.errors.push(`No backup found for ${relativePath}`);
1444
+ return result;
1445
+ }
1446
+ if (await fs.restoreBackup(relativePath)) {
1447
+ result.restored.push(relativePath);
1448
+ await fs.removeBackup(relativePath);
1449
+ } else result.errors.push(`No backup found for ${relativePath}`);
1450
+ } catch (err) {
1451
+ result.errors.push(err instanceof Error ? err.message : String(err));
1452
+ }
1453
+ return result;
1454
+ }
1455
+ async function listScaffoldableFiles(cwd, type, baseDir) {
1456
+ const fs = createFsOps(cwd);
1457
+ const relativeBase = (baseDir ? resolveBaseDir(cwd, baseDir) : getDirForType(cwd, type)).replace(`${cwd}/`, "");
1458
+ return (await fs.listFiles(relativeBase)).map((f) => f.replace(`${cwd}/`, ""));
1459
+ }
1460
+ const scaffoldTypes = [
1461
+ "page",
1462
+ "api",
1463
+ "layout",
1464
+ "middleware",
1465
+ "cron",
1466
+ "plugin"
1467
+ ];
1468
+ const allTypes = [
1469
+ "page",
1470
+ "api",
1471
+ "layout",
1472
+ "middleware",
1473
+ "reuse",
1474
+ "cron",
1475
+ "plugin"
1476
+ ];
1477
+ const pageCommand = {
1478
+ meta: {
1479
+ name: "page",
1480
+ description: "Scaffold pages, api routes, layouts, middleware, crons, and plugins"
1481
+ },
1482
+ subCommands: {
1483
+ add: {
1484
+ meta: {
1485
+ name: "add",
1486
+ description: "Add a new page, api route, layout, middleware, cron, or plugin"
1487
+ },
1488
+ args: {
1489
+ path: {
1490
+ type: "positional",
1491
+ description: "Route path (e.g., users/[id], api/users, crons/daily)"
1492
+ },
1493
+ type: {
1494
+ type: "string",
1495
+ description: "Type: page, api, layout, middleware, cron, plugin",
1496
+ default: "page"
1497
+ },
1498
+ method: {
1499
+ type: "string",
1500
+ description: "HTTP method for API routes (GET, POST, etc.)",
1501
+ default: "GET"
1502
+ },
1503
+ schedule: {
1504
+ type: "string",
1505
+ description: "Cron schedule expression for cron tasks",
1506
+ default: "* * * * *"
1507
+ },
1508
+ force: {
1509
+ type: "boolean",
1510
+ description: "Overwrite existing files (creates .bak backup)",
1511
+ default: false,
1512
+ alias: "f"
1513
+ },
1514
+ dry: {
1515
+ type: "boolean",
1516
+ description: "Show what would be created without writing files",
1517
+ default: false
1518
+ }
1519
+ },
1520
+ async run({ args }) {
1521
+ const type = args.type;
1522
+ if (!scaffoldTypes.includes(type)) {
1523
+ logger.error(`Invalid type: ${type}. Must be one of: ${scaffoldTypes.join(", ")}`);
1524
+ return;
1525
+ }
1526
+ const result = await scaffold({
1527
+ cwd: process.cwd(),
1528
+ type,
1529
+ path: args.path,
1530
+ method: args.method,
1531
+ schedule: args.schedule,
1532
+ force: args.force,
1533
+ dry: args.dry
1534
+ });
1535
+ for (const file of result.created) logger.success(`${args.dry ? "[dry-run] Would create" : "Created"} ${file}`);
1536
+ for (const file of result.skipped) logger.warn(`Skipped ${file} (already exists, use --force to overwrite)`);
1537
+ for (const err of result.errors) logger.error(err);
1538
+ }
1539
+ },
1540
+ "add-reuse": {
1541
+ meta: {
1542
+ name: "add-reuse",
1543
+ description: "Add a new reusable page component (.reuse.ts)"
1544
+ },
1545
+ args: {
1546
+ path: {
1547
+ type: "positional",
1548
+ description: "Reusable page path (e.g., users/[id])"
1549
+ },
1550
+ force: {
1551
+ type: "boolean",
1552
+ description: "Overwrite existing files (creates .bak backup)",
1553
+ default: false,
1554
+ alias: "f"
1555
+ },
1556
+ dry: {
1557
+ type: "boolean",
1558
+ description: "Show what would be created without writing files",
1559
+ default: false
1560
+ }
1561
+ },
1562
+ async run({ args }) {
1563
+ const result = await scaffold({
1564
+ cwd: process.cwd(),
1565
+ type: "reuse",
1566
+ path: args.path,
1567
+ force: args.force,
1568
+ dry: args.dry
1569
+ });
1570
+ for (const file of result.created) logger.success(`${args.dry ? "[dry-run] Would create" : "Created"} ${file}`);
1571
+ for (const file of result.skipped) logger.warn(`Skipped ${file} (already exists, use --force to overwrite)`);
1572
+ for (const err of result.errors) logger.error(err);
1573
+ }
1574
+ },
1575
+ delete: {
1576
+ meta: {
1577
+ name: "delete",
1578
+ description: "Delete a scaffold file (creates backup by default)"
1579
+ },
1580
+ args: {
1581
+ path: {
1582
+ type: "positional",
1583
+ description: "Path to delete"
1584
+ },
1585
+ type: {
1586
+ type: "string",
1587
+ description: "Type: page, api, layout, middleware, reuse, cron, plugin",
1588
+ default: "page"
1589
+ },
1590
+ force: {
1591
+ type: "boolean",
1592
+ description: "Delete permanently without creating backup",
1593
+ default: false,
1594
+ alias: "f"
1595
+ },
1596
+ dry: {
1597
+ type: "boolean",
1598
+ description: "Show what would be deleted",
1599
+ default: false
1600
+ }
1601
+ },
1602
+ async run({ args }) {
1603
+ const type = args.type;
1604
+ if (!allTypes.includes(type)) {
1605
+ logger.error(`Invalid type: ${type}. Must be one of: ${allTypes.join(", ")}`);
1606
+ return;
1607
+ }
1608
+ const result = await deleteScaffold({
1609
+ cwd: process.cwd(),
1610
+ type,
1611
+ path: args.path,
1612
+ force: args.force,
1613
+ dry: args.dry
1614
+ });
1615
+ for (const file of result.deleted) logger.success(`${args.dry ? "[dry-run] Would delete" : args.force ? "Deleted" : "Deleted (backup created)"} ${file}`);
1616
+ for (const err of result.errors) logger.error(err);
1617
+ }
1618
+ },
1619
+ recovery: {
1620
+ meta: {
1621
+ name: "recovery",
1622
+ description: "Recover a deleted file from .bak backup"
1623
+ },
1624
+ args: {
1625
+ path: {
1626
+ type: "positional",
1627
+ description: "Path to recover"
1628
+ },
1629
+ type: {
1630
+ type: "string",
1631
+ description: "Type: page, api, layout, middleware, reuse, cron, plugin",
1632
+ default: "page"
1633
+ },
1634
+ dry: {
1635
+ type: "boolean",
1636
+ description: "Check if recovery is possible",
1637
+ default: false
1638
+ }
1639
+ },
1640
+ async run({ args }) {
1641
+ const type = args.type;
1642
+ if (!allTypes.includes(type)) {
1643
+ logger.error(`Invalid type: ${type}. Must be one of: ${allTypes.join(", ")}`);
1644
+ return;
1645
+ }
1646
+ const result = await recoverScaffold({
1647
+ cwd: process.cwd(),
1648
+ type,
1649
+ path: args.path,
1650
+ dry: args.dry
1651
+ });
1652
+ for (const file of result.restored) logger.success(`${args.dry ? "[dry-run] Backup exists for" : "Recovered"} ${file}`);
1653
+ for (const err of result.errors) logger.error(err);
1654
+ }
1655
+ },
1656
+ list: {
1657
+ meta: {
1658
+ name: "list",
1659
+ description: "List existing scaffoldable files"
1660
+ },
1661
+ args: { type: {
1662
+ type: "string",
1663
+ description: "Type to list: page, api, layout, middleware, reuse, cron, plugin",
1664
+ default: "page"
1665
+ } },
1666
+ async run({ args }) {
1667
+ const type = args.type;
1668
+ const files = await listScaffoldableFiles(process.cwd(), type);
1669
+ if (files.length === 0) {
1670
+ logger.info(`No ${type} files found`);
1671
+ return;
1672
+ }
1673
+ logger.info(`Found ${files.length} ${type}(s):`);
1674
+ for (const file of files) logger.log(` ${file}`);
1675
+ }
1676
+ }
1677
+ }
1678
+ };
1679
+ //#endregion
1680
+ export { toCamelCase as C, createFsOps as E, renderTemplate as S, toPascalCase as T, renderCronTemplate as _, scaffold as a, renderPageTemplate as b, scaffoldStarterTemplate as c, CRON_TEMPLATE as d, LAYOUT_TEMPLATE as f, renderApiTemplate as g, PLUGIN_TEMPLATE as h, recoverScaffold as i, scaffoldUnifyTemplate as l, PAGE_TEMPLATE as m, listScaffoldableFiles as n, scaffoldBlogTemplate as o, MIDDLEWARE_TEMPLATE as p, pageCommand as r, scaffoldMinimalTemplate as s, deleteScaffold as t, API_TEMPLATE as u, renderLayoutTemplate as v, toKebabCase as w, renderPluginTemplate as x, renderMiddlewareTemplate as y };