@pracht/cli 1.1.5 → 1.2.1

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.
@@ -1,17 +1,15 @@
1
1
  import { readFileSync, writeFileSync } from "node:fs";
2
2
 
3
+ import { defineCommand } from "citty";
4
+
3
5
  import {
4
6
  ensureTrailingNewline,
5
7
  parseApiMethods,
6
8
  parseCommaList,
7
- parseFlags,
8
- printGenerateHelp,
9
9
  quote,
10
- requireEnumOption,
11
- requireOptionalString,
12
- requirePositiveIntegerOption,
13
- requireStringOption,
14
- } from "../cli.js";
10
+ requireEnum,
11
+ requirePositiveInteger,
12
+ } from "../utils.js";
15
13
  import {
16
14
  extractRegistryEntries,
17
15
  insertArrayItem,
@@ -29,20 +27,103 @@ import {
29
27
  resolveRouteModulePath,
30
28
  resolveScopedFile,
31
29
  writeGeneratedFile,
30
+ type ProjectConfig,
32
31
  } from "../project.js";
33
32
 
34
- export async function generateCommand(args) {
35
- const [kind, ...rest] = args;
36
- if (!kind || kind === "--help" || kind === "-h") {
37
- printGenerateHelp();
38
- return;
39
- }
40
-
41
- const options = parseFlags(rest);
42
- const project = readProjectConfig(process.cwd());
43
- const result = runGenerate(kind, options, project);
33
+ interface GenerateResult {
34
+ created: string[];
35
+ kind: string;
36
+ updated: string[];
37
+ }
44
38
 
45
- if (options.json) {
39
+ const routeCommand = defineCommand({
40
+ meta: {
41
+ name: "route",
42
+ description: "Scaffold a route module",
43
+ },
44
+ args: {
45
+ path: { type: "string", required: true, description: "Route path (e.g. /dashboard)" },
46
+ render: { type: "string", description: "Render mode: ssr, spa, ssg, or isg" },
47
+ shell: { type: "string", description: "Shell name" },
48
+ middleware: { type: "string", description: "Middleware names (comma-separated)" },
49
+ loader: { type: "boolean", description: "Include loader" },
50
+ "error-boundary": { type: "boolean", description: "Include error boundary" },
51
+ "static-paths": { type: "boolean", description: "Include static paths" },
52
+ title: { type: "string", description: "Page title" },
53
+ revalidate: { type: "string", description: "ISG revalidation seconds" },
54
+ json: { type: "boolean", description: "Output as JSON" },
55
+ },
56
+ async run({ args }) {
57
+ const project = readProjectConfig(process.cwd());
58
+ const result = generateRoute(args, project);
59
+ outputResult(result, Boolean(args.json));
60
+ },
61
+ });
62
+
63
+ const shellCommand = defineCommand({
64
+ meta: {
65
+ name: "shell",
66
+ description: "Scaffold a shell component",
67
+ },
68
+ args: {
69
+ name: { type: "string", required: true, description: "Shell name" },
70
+ json: { type: "boolean", description: "Output as JSON" },
71
+ },
72
+ async run({ args }) {
73
+ const project = readProjectConfig(process.cwd());
74
+ const result = generateShell(args.name, project);
75
+ outputResult(result, Boolean(args.json));
76
+ },
77
+ });
78
+
79
+ const middlewareCommand = defineCommand({
80
+ meta: {
81
+ name: "middleware",
82
+ description: "Scaffold a middleware function",
83
+ },
84
+ args: {
85
+ name: { type: "string", required: true, description: "Middleware name" },
86
+ json: { type: "boolean", description: "Output as JSON" },
87
+ },
88
+ async run({ args }) {
89
+ const project = readProjectConfig(process.cwd());
90
+ const result = generateMiddleware(args.name, project);
91
+ outputResult(result, Boolean(args.json));
92
+ },
93
+ });
94
+
95
+ const apiCommand = defineCommand({
96
+ meta: {
97
+ name: "api",
98
+ description: "Scaffold an API route",
99
+ },
100
+ args: {
101
+ path: { type: "string", required: true, description: "API endpoint path" },
102
+ methods: { type: "string", description: "HTTP methods (comma-separated, e.g. GET,POST)" },
103
+ json: { type: "boolean", description: "Output as JSON" },
104
+ },
105
+ async run({ args }) {
106
+ const project = readProjectConfig(process.cwd());
107
+ const result = generateApi(args, project);
108
+ outputResult(result, Boolean(args.json));
109
+ },
110
+ });
111
+
112
+ export default defineCommand({
113
+ meta: {
114
+ name: "generate",
115
+ description: "Scaffold framework files",
116
+ },
117
+ subCommands: {
118
+ route: routeCommand,
119
+ shell: shellCommand,
120
+ middleware: middlewareCommand,
121
+ api: apiCommand,
122
+ },
123
+ });
124
+
125
+ function outputResult(result: GenerateResult, json: boolean): void {
126
+ if (json) {
46
127
  console.log(JSON.stringify({ ok: true, ...result }, null, 2));
47
128
  return;
48
129
  }
@@ -56,36 +137,31 @@ export async function generateCommand(args) {
56
137
  }
57
138
  }
58
139
 
59
- function runGenerate(kind, options, project) {
60
- if (kind === "route") {
61
- return generateRoute(options, project);
62
- }
63
- if (kind === "shell") {
64
- return generateShell(options, project);
65
- }
66
- if (kind === "middleware") {
67
- return generateMiddleware(options, project);
68
- }
69
- if (kind === "api") {
70
- return generateApi(options, project);
71
- }
72
-
73
- throw new Error(`Unknown generate kind: ${kind}`);
140
+ interface RouteArgs {
141
+ "error-boundary"?: boolean;
142
+ loader?: boolean;
143
+ middleware?: string;
144
+ path: string;
145
+ render?: string;
146
+ revalidate?: string;
147
+ shell?: string;
148
+ "static-paths"?: boolean;
149
+ title?: string;
74
150
  }
75
151
 
76
- function generateRoute(options, project) {
77
- const routePath = normalizeRoutePathString(requireStringOption(options, "path"));
78
- const render = requireEnumOption(options, "render", ["spa", "ssr", "ssg", "isg"], "ssr");
79
- const includeLoader = Boolean(options.loader);
80
- const includeErrorBoundary = Boolean(options["error-boundary"]);
81
- const middleware = parseCommaList(options.middleware);
152
+ function generateRoute(args: RouteArgs, project: ProjectConfig): GenerateResult {
153
+ const routePath = normalizeRoutePathString(args.path);
154
+ const render = requireEnum(args.render, "render", ["spa", "ssr", "ssg", "isg"], "ssr");
155
+ const includeLoader = Boolean(args.loader);
156
+ const includeErrorBoundary = Boolean(args["error-boundary"]);
157
+ const middleware = parseCommaList(args.middleware);
82
158
  const includeStaticPaths =
83
- Boolean(options["static-paths"]) ||
159
+ Boolean(args["static-paths"]) ||
84
160
  (hasDynamicSegments(routePath) && (render === "ssg" || render === "isg"));
85
- const title = requireOptionalString(options, "title") ?? titleFromPath(routePath);
161
+ const title = args.title ?? titleFromPath(routePath);
86
162
 
87
163
  if (project.mode === "pages") {
88
- if (options.shell) {
164
+ if (args.shell) {
89
165
  throw new Error("`pracht generate route --shell` is only available for manifest apps.");
90
166
  }
91
167
  if (middleware.length > 0) {
@@ -113,7 +189,7 @@ function generateRoute(options, project) {
113
189
  extractRegistryEntries(manifestSource, "middleware").map((entry) => entry.name),
114
190
  );
115
191
 
116
- const shellName = requireOptionalString(options, "shell");
192
+ const shellName = args.shell;
117
193
  if (shellName && !registeredShells.has(shellName)) {
118
194
  throw new Error(`Shell "${shellName}" is not registered in ${project.appFile}.`);
119
195
  }
@@ -152,7 +228,7 @@ function generateRoute(options, project) {
152
228
  meta.push(`middleware: [${middleware.map((item) => quote(item)).join(", ")}]`);
153
229
  }
154
230
  if (render === "isg") {
155
- const seconds = requirePositiveIntegerOption(options, "revalidate", 3600);
231
+ const seconds = requirePositiveInteger(args.revalidate, "revalidate", 3600);
156
232
  meta.push(`revalidate: timeRevalidate(${seconds})`);
157
233
  }
158
234
 
@@ -182,7 +258,15 @@ function generatePagesRoute({
182
258
  render,
183
259
  routePath,
184
260
  title,
185
- }) {
261
+ }: {
262
+ includeErrorBoundary: boolean;
263
+ includeLoader: boolean;
264
+ includeStaticPaths: boolean;
265
+ project: ProjectConfig;
266
+ render: string;
267
+ routePath: string;
268
+ title: string;
269
+ }): GenerateResult {
186
270
  const routeFile = resolvePagesRouteModulePath(project, routePath, ".tsx");
187
271
  writeGeneratedFile(
188
272
  routeFile.absolutePath,
@@ -203,14 +287,13 @@ function generatePagesRoute({
203
287
  };
204
288
  }
205
289
 
206
- function generateShell(options, project) {
290
+ function generateShell(name: string, project: ProjectConfig): GenerateResult {
207
291
  if (project.mode === "pages") {
208
292
  throw new Error(
209
293
  "Pages router apps use a single `_app` shell. `pracht generate shell` is only available for manifest apps.",
210
294
  );
211
295
  }
212
296
 
213
- const name = requireStringOption(options, "name");
214
297
  const manifestPath = resolveProjectPath(project.root, project.appFile);
215
298
  assertFileExists(manifestPath, `App manifest not found at ${project.appFile}.`);
216
299
 
@@ -232,14 +315,13 @@ function generateShell(options, project) {
232
315
  };
233
316
  }
234
317
 
235
- function generateMiddleware(options, project) {
318
+ function generateMiddleware(name: string, project: ProjectConfig): GenerateResult {
236
319
  if (project.mode === "pages") {
237
320
  throw new Error(
238
321
  "Pages router apps do not use manifest middleware registration. `pracht generate middleware` is only available for manifest apps.",
239
322
  );
240
323
  }
241
324
 
242
- const name = requireStringOption(options, "name");
243
325
  const manifestPath = resolveProjectPath(project.root, project.appFile);
244
326
  assertFileExists(manifestPath, `App manifest not found at ${project.appFile}.`);
245
327
 
@@ -261,9 +343,14 @@ function generateMiddleware(options, project) {
261
343
  };
262
344
  }
263
345
 
264
- function generateApi(options, project) {
265
- const endpointPath = normalizeApiPath(requireStringOption(options, "path"));
266
- const methods = parseApiMethods(options.methods);
346
+ interface ApiArgs {
347
+ methods?: string;
348
+ path: string;
349
+ }
350
+
351
+ function generateApi(args: ApiArgs, project: ProjectConfig): GenerateResult {
352
+ const endpointPath = normalizeApiPath(args.path);
353
+ const methods = parseApiMethods(args.methods);
267
354
  const apiFile = resolveApiModulePath(project, endpointPath);
268
355
  writeGeneratedFile(apiFile.absolutePath, buildApiRouteSource({ endpointPath, methods }));
269
356
 
@@ -274,16 +361,19 @@ function generateApi(options, project) {
274
361
  };
275
362
  }
276
363
 
277
- function buildManifestRouteModuleSource({
278
- includeErrorBoundary,
279
- includeLoader,
280
- includeStaticPaths,
281
- routePath,
282
- title,
283
- }) {
364
+ interface RouteModuleParts {
365
+ includeErrorBoundary: boolean;
366
+ includeLoader: boolean;
367
+ includeStaticPaths: boolean;
368
+ routePath: string;
369
+ title: string;
370
+ }
371
+
372
+ function buildRouteModuleSections(opts: RouteModuleParts): string[] {
373
+ const { includeErrorBoundary, includeLoader, includeStaticPaths, routePath, title } = opts;
284
374
  const params = dynamicParamNames(routePath);
285
- const imports = [];
286
- const sections = [];
375
+ const imports: string[] = [];
376
+ const sections: string[] = [];
287
377
 
288
378
  if (includeLoader) {
289
379
  imports.push("LoaderArgs", "RouteComponentProps");
@@ -315,8 +405,6 @@ function buildManifestRouteModuleSource({
315
405
  );
316
406
  }
317
407
 
318
- sections.push("export function head() {", ` return { title: ${quote(title)} };`, "}", "");
319
-
320
408
  if (includeLoader) {
321
409
  sections.push(
322
410
  "export function Component({ data }: RouteComponentProps<typeof loader>) {",
@@ -349,89 +437,39 @@ function buildManifestRouteModuleSource({
349
437
  );
350
438
  }
351
439
 
352
- return `${sections.join("\n")}\n`;
440
+ return sections;
353
441
  }
354
442
 
355
- function buildPagesRouteModuleSource({
356
- includeErrorBoundary,
357
- includeLoader,
358
- includeStaticPaths,
359
- render,
360
- routePath,
361
- title,
362
- }) {
363
- const params = dynamicParamNames(routePath);
364
- const imports = [];
365
- const sections = [];
366
-
367
- if (includeLoader) {
368
- imports.push("LoaderArgs", "RouteComponentProps");
369
- }
370
- if (includeErrorBoundary) {
371
- imports.push("ErrorBoundaryProps");
372
- }
443
+ function buildManifestRouteModuleSource(opts: RouteModuleParts): string {
444
+ const sections = buildRouteModuleSections(opts);
373
445
 
374
- if (imports.length > 0) {
375
- sections.push(`import type { ${imports.join(", ")} } from "@pracht/core";`);
376
- sections.push("");
377
- }
378
-
379
- sections.push(`export const RENDER_MODE = ${quote(render)};`, "");
380
-
381
- if (includeLoader) {
382
- sections.push(
383
- "export async function loader(_args: LoaderArgs) {",
384
- ` return { message: ${quote(`Welcome to ${title}.`)} };`,
385
- "}",
386
- "",
387
- );
388
- }
446
+ // Insert head() before the Component export (after loader/getStaticPaths)
447
+ const componentIdx = sections.findIndex((s) => s.startsWith("export function Component"));
448
+ const insertAt = componentIdx === -1 ? sections.length : componentIdx;
449
+ sections.splice(
450
+ insertAt,
451
+ 0,
452
+ "export function head() {",
453
+ ` return { title: ${quote(opts.title)} };`,
454
+ "}",
455
+ "",
456
+ );
389
457
 
390
- if (includeStaticPaths) {
391
- sections.push(
392
- "export function getStaticPaths() {",
393
- ` return [${buildStaticPathsStub(params)}];`,
394
- "}",
395
- "",
396
- );
397
- }
458
+ return `${sections.join("\n")}\n`;
459
+ }
398
460
 
399
- if (includeLoader) {
400
- sections.push(
401
- "export function Component({ data }: RouteComponentProps<typeof loader>) {",
402
- " return (",
403
- " <section>",
404
- ` <h1>${escapeJsxText(title)}</h1>`,
405
- " <p>{data.message}</p>",
406
- " </section>",
407
- " );",
408
- "}",
409
- );
410
- } else {
411
- sections.push(
412
- "export function Component() {",
413
- " return (",
414
- " <section>",
415
- ` <h1>${escapeJsxText(title)}</h1>`,
416
- " </section>",
417
- " );",
418
- "}",
419
- );
420
- }
461
+ function buildPagesRouteModuleSource(opts: RouteModuleParts & { render: string }): string {
462
+ const sections = buildRouteModuleSections(opts);
421
463
 
422
- if (includeErrorBoundary) {
423
- sections.push(
424
- "",
425
- "export function ErrorBoundary({ error }: ErrorBoundaryProps) {",
426
- " return <p>{error.message}</p>;",
427
- "}",
428
- );
429
- }
464
+ // Insert RENDER_MODE before the first exported declaration (after imports)
465
+ const firstExportIdx = sections.findIndex((s) => s.startsWith("export"));
466
+ const insertAt = firstExportIdx === -1 ? sections.length : firstExportIdx;
467
+ sections.splice(insertAt, 0, `export const RENDER_MODE = ${quote(opts.render)};`, "");
430
468
 
431
469
  return `${sections.join("\n")}\n`;
432
470
  }
433
471
 
434
- function buildShellModuleSource(name) {
472
+ function buildShellModuleSource(name: string): string {
435
473
  const title = titleCase(name);
436
474
  return [
437
475
  'import type { ShellProps } from "@pracht/core";',
@@ -451,7 +489,7 @@ function buildShellModuleSource(name) {
451
489
  ].join("\n");
452
490
  }
453
491
 
454
- function buildMiddlewareModuleSource() {
492
+ function buildMiddlewareModuleSource(): string {
455
493
  return [
456
494
  'import type { MiddlewareFn } from "@pracht/core";',
457
495
  "",
@@ -462,7 +500,13 @@ function buildMiddlewareModuleSource() {
462
500
  ].join("\n");
463
501
  }
464
502
 
465
- function buildApiRouteSource({ endpointPath, methods }) {
503
+ function buildApiRouteSource({
504
+ endpointPath,
505
+ methods,
506
+ }: {
507
+ endpointPath: string;
508
+ methods: string[];
509
+ }): string {
466
510
  const methodLines = methods.flatMap((method, index) => {
467
511
  const lines = buildApiMethodSource(method, methods, endpointPath);
468
512
  if (index === methods.length - 1) return lines;
@@ -472,7 +516,7 @@ function buildApiRouteSource({ endpointPath, methods }) {
472
516
  return ['import type { BaseRouteArgs } from "@pracht/core";', "", ...methodLines, ""].join("\n");
473
517
  }
474
518
 
475
- function buildApiMethodSource(method, methods, endpointPath) {
519
+ function buildApiMethodSource(method: string, methods: string[], endpointPath: string): string[] {
476
520
  if (method === "DELETE" || method === "HEAD") {
477
521
  return [
478
522
  `export function ${method}(_args: BaseRouteArgs) {`,
@@ -509,22 +553,22 @@ function buildApiMethodSource(method, methods, endpointPath) {
509
553
  ];
510
554
  }
511
555
 
512
- function normalizeRoutePathString(value) {
556
+ function normalizeRoutePathString(value: string): string {
513
557
  if (!value || value === "/") return "/";
514
558
  const normalized = `/${value}`.replace(/\/+/g, "/");
515
559
  return normalized !== "/" && normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
516
560
  }
517
561
 
518
- function normalizeApiPath(value) {
562
+ function normalizeApiPath(value: string): string {
519
563
  const normalized = normalizeRoutePathString(value).replace(/^\/api(?=\/|$)/, "");
520
564
  return normalized || "/";
521
565
  }
522
566
 
523
- function hasDynamicSegments(routePath) {
567
+ function hasDynamicSegments(routePath: string): boolean {
524
568
  return routePath.split("/").some((segment) => segment.startsWith(":") || segment === "*");
525
569
  }
526
570
 
527
- function dynamicParamNames(routePath) {
571
+ function dynamicParamNames(routePath: string): string[] {
528
572
  return routePath
529
573
  .split("/")
530
574
  .filter(Boolean)
@@ -533,10 +577,10 @@ function dynamicParamNames(routePath) {
533
577
  if (segment === "*") return "slug";
534
578
  return null;
535
579
  })
536
- .filter(Boolean);
580
+ .filter((s): s is string => s !== null);
537
581
  }
538
582
 
539
- function routeIdFromPath(routePath) {
583
+ function routeIdFromPath(routePath: string): string {
540
584
  if (routePath === "/") return "index";
541
585
  return routePath
542
586
  .split("/")
@@ -545,13 +589,13 @@ function routeIdFromPath(routePath) {
545
589
  .join("-");
546
590
  }
547
591
 
548
- function titleFromPath(routePath) {
592
+ function titleFromPath(routePath: string): string {
549
593
  if (routePath === "/") return "Home";
550
594
  const lastSegment = routePath.split("/").filter(Boolean).at(-1) ?? "Page";
551
595
  return titleCase(lastSegment.replace(/^:/, "").replace(/\*/g, "slug"));
552
596
  }
553
597
 
554
- function titleCase(value) {
598
+ function titleCase(value: string): string {
555
599
  return value
556
600
  .split(/[-_/]/)
557
601
  .filter(Boolean)
@@ -559,7 +603,7 @@ function titleCase(value) {
559
603
  .join(" ");
560
604
  }
561
605
 
562
- function buildStaticPathsStub(params) {
606
+ function buildStaticPathsStub(params: string[]): string {
563
607
  if (params.length === 0) {
564
608
  return "{}";
565
609
  }
@@ -567,6 +611,6 @@ function buildStaticPathsStub(params) {
567
611
  return `{ ${params.map((name) => `${name}: ${quote(`example-${name}`)}`).join(", ")} }`;
568
612
  }
569
613
 
570
- function escapeJsxText(value) {
614
+ function escapeJsxText(value: string): string {
571
615
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
572
616
  }