@pracht/cli 1.2.1 → 1.2.2

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,616 +0,0 @@
1
- import { readFileSync, writeFileSync } from "node:fs";
2
-
3
- import { defineCommand } from "citty";
4
-
5
- import {
6
- ensureTrailingNewline,
7
- parseApiMethods,
8
- parseCommaList,
9
- quote,
10
- requireEnum,
11
- requirePositiveInteger,
12
- } from "../utils.js";
13
- import {
14
- extractRegistryEntries,
15
- insertArrayItem,
16
- toManifestModulePath,
17
- upsertObjectEntry,
18
- ensureCoreNamedImport,
19
- } from "../manifest.js";
20
- import {
21
- assertFileExists,
22
- displayPath,
23
- readProjectConfig,
24
- resolveApiModulePath,
25
- resolvePagesRouteModulePath,
26
- resolveProjectPath,
27
- resolveRouteModulePath,
28
- resolveScopedFile,
29
- writeGeneratedFile,
30
- type ProjectConfig,
31
- } from "../project.js";
32
-
33
- interface GenerateResult {
34
- created: string[];
35
- kind: string;
36
- updated: string[];
37
- }
38
-
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) {
127
- console.log(JSON.stringify({ ok: true, ...result }, null, 2));
128
- return;
129
- }
130
-
131
- console.log(`Created ${result.kind}:`);
132
- for (const file of result.created) {
133
- console.log(` ${file}`);
134
- }
135
- for (const file of result.updated) {
136
- console.log(` updated ${file}`);
137
- }
138
- }
139
-
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;
150
- }
151
-
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);
158
- const includeStaticPaths =
159
- Boolean(args["static-paths"]) ||
160
- (hasDynamicSegments(routePath) && (render === "ssg" || render === "isg"));
161
- const title = args.title ?? titleFromPath(routePath);
162
-
163
- if (project.mode === "pages") {
164
- if (args.shell) {
165
- throw new Error("`pracht generate route --shell` is only available for manifest apps.");
166
- }
167
- if (middleware.length > 0) {
168
- throw new Error("`pracht generate route --middleware` is only available for manifest apps.");
169
- }
170
- return generatePagesRoute({
171
- includeErrorBoundary,
172
- includeLoader,
173
- includeStaticPaths,
174
- project,
175
- render,
176
- routePath,
177
- title,
178
- });
179
- }
180
-
181
- const manifestPath = resolveProjectPath(project.root, project.appFile);
182
- assertFileExists(manifestPath, `App manifest not found at ${project.appFile}.`);
183
-
184
- const manifestSource = readFileSync(manifestPath, "utf-8");
185
- const registeredShells = new Set(
186
- extractRegistryEntries(manifestSource, "shells").map((entry) => entry.name),
187
- );
188
- const registeredMiddleware = new Set(
189
- extractRegistryEntries(manifestSource, "middleware").map((entry) => entry.name),
190
- );
191
-
192
- const shellName = args.shell;
193
- if (shellName && !registeredShells.has(shellName)) {
194
- throw new Error(`Shell "${shellName}" is not registered in ${project.appFile}.`);
195
- }
196
-
197
- for (const name of middleware) {
198
- if (!registeredMiddleware.has(name)) {
199
- throw new Error(`Middleware "${name}" is not registered in ${project.appFile}.`);
200
- }
201
- }
202
-
203
- const routeFile = resolveRouteModulePath(project, routePath, ".tsx");
204
- writeGeneratedFile(
205
- routeFile.absolutePath,
206
- buildManifestRouteModuleSource({
207
- includeErrorBoundary,
208
- includeLoader,
209
- includeStaticPaths,
210
- routePath,
211
- title,
212
- }),
213
- );
214
-
215
- let nextManifestSource = ensureCoreNamedImport(manifestSource, "route");
216
- if (render === "isg") {
217
- nextManifestSource = ensureCoreNamedImport(nextManifestSource, "timeRevalidate");
218
- }
219
-
220
- const routeModulePath = toManifestModulePath(manifestPath, routeFile.absolutePath);
221
- const routeId = routeIdFromPath(routePath);
222
- const meta = [`id: ${quote(routeId)}`, `render: ${quote(render)}`];
223
-
224
- if (shellName) {
225
- meta.push(`shell: ${quote(shellName)}`);
226
- }
227
- if (middleware.length > 0) {
228
- meta.push(`middleware: [${middleware.map((item) => quote(item)).join(", ")}]`);
229
- }
230
- if (render === "isg") {
231
- const seconds = requirePositiveInteger(args.revalidate, "revalidate", 3600);
232
- meta.push(`revalidate: timeRevalidate(${seconds})`);
233
- }
234
-
235
- nextManifestSource = insertArrayItem(
236
- nextManifestSource,
237
- "routes",
238
- [
239
- `route(${quote(routePath)}, ${quote(routeModulePath)}, {`,
240
- ...meta.map((line) => ` ${line},`),
241
- "})",
242
- ].join("\n"),
243
- );
244
- writeFileSync(manifestPath, ensureTrailingNewline(nextManifestSource), "utf-8");
245
-
246
- return {
247
- created: [displayPath(project.root, routeFile.absolutePath)],
248
- kind: "route",
249
- updated: [displayPath(project.root, manifestPath)],
250
- };
251
- }
252
-
253
- function generatePagesRoute({
254
- includeErrorBoundary,
255
- includeLoader,
256
- includeStaticPaths,
257
- project,
258
- render,
259
- routePath,
260
- title,
261
- }: {
262
- includeErrorBoundary: boolean;
263
- includeLoader: boolean;
264
- includeStaticPaths: boolean;
265
- project: ProjectConfig;
266
- render: string;
267
- routePath: string;
268
- title: string;
269
- }): GenerateResult {
270
- const routeFile = resolvePagesRouteModulePath(project, routePath, ".tsx");
271
- writeGeneratedFile(
272
- routeFile.absolutePath,
273
- buildPagesRouteModuleSource({
274
- includeErrorBoundary,
275
- includeLoader,
276
- includeStaticPaths,
277
- render,
278
- routePath,
279
- title,
280
- }),
281
- );
282
-
283
- return {
284
- created: [displayPath(project.root, routeFile.absolutePath)],
285
- kind: "route",
286
- updated: [],
287
- };
288
- }
289
-
290
- function generateShell(name: string, project: ProjectConfig): GenerateResult {
291
- if (project.mode === "pages") {
292
- throw new Error(
293
- "Pages router apps use a single `_app` shell. `pracht generate shell` is only available for manifest apps.",
294
- );
295
- }
296
-
297
- const manifestPath = resolveProjectPath(project.root, project.appFile);
298
- assertFileExists(manifestPath, `App manifest not found at ${project.appFile}.`);
299
-
300
- const shellFile = resolveScopedFile(project.root, project.shellsDir, `${name}.tsx`);
301
- writeGeneratedFile(shellFile, buildShellModuleSource(name));
302
-
303
- const manifestSource = readFileSync(manifestPath, "utf-8");
304
- const updatedSource = upsertObjectEntry(
305
- manifestSource,
306
- "shells",
307
- `${name}: ${quote(toManifestModulePath(manifestPath, shellFile))}`,
308
- );
309
- writeFileSync(manifestPath, ensureTrailingNewline(updatedSource), "utf-8");
310
-
311
- return {
312
- created: [displayPath(project.root, shellFile)],
313
- kind: "shell",
314
- updated: [displayPath(project.root, manifestPath)],
315
- };
316
- }
317
-
318
- function generateMiddleware(name: string, project: ProjectConfig): GenerateResult {
319
- if (project.mode === "pages") {
320
- throw new Error(
321
- "Pages router apps do not use manifest middleware registration. `pracht generate middleware` is only available for manifest apps.",
322
- );
323
- }
324
-
325
- const manifestPath = resolveProjectPath(project.root, project.appFile);
326
- assertFileExists(manifestPath, `App manifest not found at ${project.appFile}.`);
327
-
328
- const middlewareFile = resolveScopedFile(project.root, project.middlewareDir, `${name}.ts`);
329
- writeGeneratedFile(middlewareFile, buildMiddlewareModuleSource());
330
-
331
- const manifestSource = readFileSync(manifestPath, "utf-8");
332
- const updatedSource = upsertObjectEntry(
333
- manifestSource,
334
- "middleware",
335
- `${name}: ${quote(toManifestModulePath(manifestPath, middlewareFile))}`,
336
- );
337
- writeFileSync(manifestPath, ensureTrailingNewline(updatedSource), "utf-8");
338
-
339
- return {
340
- created: [displayPath(project.root, middlewareFile)],
341
- kind: "middleware",
342
- updated: [displayPath(project.root, manifestPath)],
343
- };
344
- }
345
-
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);
354
- const apiFile = resolveApiModulePath(project, endpointPath);
355
- writeGeneratedFile(apiFile.absolutePath, buildApiRouteSource({ endpointPath, methods }));
356
-
357
- return {
358
- created: [displayPath(project.root, apiFile.absolutePath)],
359
- kind: "api",
360
- updated: [],
361
- };
362
- }
363
-
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;
374
- const params = dynamicParamNames(routePath);
375
- const imports: string[] = [];
376
- const sections: string[] = [];
377
-
378
- if (includeLoader) {
379
- imports.push("LoaderArgs", "RouteComponentProps");
380
- }
381
- if (includeErrorBoundary) {
382
- imports.push("ErrorBoundaryProps");
383
- }
384
-
385
- if (imports.length > 0) {
386
- sections.push(`import type { ${imports.join(", ")} } from "@pracht/core";`);
387
- sections.push("");
388
- }
389
-
390
- if (includeLoader) {
391
- sections.push(
392
- "export async function loader(_args: LoaderArgs) {",
393
- ` return { message: ${quote(`Welcome to ${title}.`)} };`,
394
- "}",
395
- "",
396
- );
397
- }
398
-
399
- if (includeStaticPaths) {
400
- sections.push(
401
- "export function getStaticPaths() {",
402
- ` return [${buildStaticPathsStub(params)}];`,
403
- "}",
404
- "",
405
- );
406
- }
407
-
408
- if (includeLoader) {
409
- sections.push(
410
- "export function Component({ data }: RouteComponentProps<typeof loader>) {",
411
- " return (",
412
- " <section>",
413
- ` <h1>${escapeJsxText(title)}</h1>`,
414
- " <p>{data.message}</p>",
415
- " </section>",
416
- " );",
417
- "}",
418
- );
419
- } else {
420
- sections.push(
421
- "export function Component() {",
422
- " return (",
423
- " <section>",
424
- ` <h1>${escapeJsxText(title)}</h1>`,
425
- " </section>",
426
- " );",
427
- "}",
428
- );
429
- }
430
-
431
- if (includeErrorBoundary) {
432
- sections.push(
433
- "",
434
- "export function ErrorBoundary({ error }: ErrorBoundaryProps) {",
435
- " return <p>{error.message}</p>;",
436
- "}",
437
- );
438
- }
439
-
440
- return sections;
441
- }
442
-
443
- function buildManifestRouteModuleSource(opts: RouteModuleParts): string {
444
- const sections = buildRouteModuleSections(opts);
445
-
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
- );
457
-
458
- return `${sections.join("\n")}\n`;
459
- }
460
-
461
- function buildPagesRouteModuleSource(opts: RouteModuleParts & { render: string }): string {
462
- const sections = buildRouteModuleSections(opts);
463
-
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)};`, "");
468
-
469
- return `${sections.join("\n")}\n`;
470
- }
471
-
472
- function buildShellModuleSource(name: string): string {
473
- const title = titleCase(name);
474
- return [
475
- 'import type { ShellProps } from "@pracht/core";',
476
- "",
477
- "export function Shell({ children }: ShellProps) {",
478
- " return (",
479
- ` <div class=${quote(`${name}-shell`)}>`,
480
- " <main>{children}</main>",
481
- " </div>",
482
- " );",
483
- "}",
484
- "",
485
- "export function head() {",
486
- ` return { title: ${quote(title)} };`,
487
- "}",
488
- "",
489
- ].join("\n");
490
- }
491
-
492
- function buildMiddlewareModuleSource(): string {
493
- return [
494
- 'import type { MiddlewareFn } from "@pracht/core";',
495
- "",
496
- "export const middleware: MiddlewareFn = async (_args) => {",
497
- " return;",
498
- "};",
499
- "",
500
- ].join("\n");
501
- }
502
-
503
- function buildApiRouteSource({
504
- endpointPath,
505
- methods,
506
- }: {
507
- endpointPath: string;
508
- methods: string[];
509
- }): string {
510
- const methodLines = methods.flatMap((method, index) => {
511
- const lines = buildApiMethodSource(method, methods, endpointPath);
512
- if (index === methods.length - 1) return lines;
513
- return [...lines, ""];
514
- });
515
-
516
- return ['import type { BaseRouteArgs } from "@pracht/core";', "", ...methodLines, ""].join("\n");
517
- }
518
-
519
- function buildApiMethodSource(method: string, methods: string[], endpointPath: string): string[] {
520
- if (method === "DELETE" || method === "HEAD") {
521
- return [
522
- `export function ${method}(_args: BaseRouteArgs) {`,
523
- " return new Response(null, { status: 204 });",
524
- "}",
525
- ];
526
- }
527
-
528
- if (method === "OPTIONS") {
529
- return [
530
- `export function ${method}(_args: BaseRouteArgs) {`,
531
- " return new Response(null, {",
532
- ` headers: { allow: ${quote(methods.join(", "))} },`,
533
- " status: 204,",
534
- " });",
535
- "}",
536
- ];
537
- }
538
-
539
- if (method === "GET") {
540
- return [
541
- `export function ${method}(_args: BaseRouteArgs) {`,
542
- ` return Response.json({ endpoint: ${quote(`/api${endpointPath}`)}, ok: true });`,
543
- "}",
544
- ];
545
- }
546
-
547
- const status = method === "POST" ? 201 : 200;
548
- return [
549
- `export async function ${method}({ request }: BaseRouteArgs) {`,
550
- " const body = await request.json();",
551
- ` return Response.json({ body, ok: true }, { status: ${status} });`,
552
- "}",
553
- ];
554
- }
555
-
556
- function normalizeRoutePathString(value: string): string {
557
- if (!value || value === "/") return "/";
558
- const normalized = `/${value}`.replace(/\/+/g, "/");
559
- return normalized !== "/" && normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
560
- }
561
-
562
- function normalizeApiPath(value: string): string {
563
- const normalized = normalizeRoutePathString(value).replace(/^\/api(?=\/|$)/, "");
564
- return normalized || "/";
565
- }
566
-
567
- function hasDynamicSegments(routePath: string): boolean {
568
- return routePath.split("/").some((segment) => segment.startsWith(":") || segment === "*");
569
- }
570
-
571
- function dynamicParamNames(routePath: string): string[] {
572
- return routePath
573
- .split("/")
574
- .filter(Boolean)
575
- .map((segment) => {
576
- if (segment.startsWith(":")) return segment.slice(1);
577
- if (segment === "*") return "slug";
578
- return null;
579
- })
580
- .filter((s): s is string => s !== null);
581
- }
582
-
583
- function routeIdFromPath(routePath: string): string {
584
- if (routePath === "/") return "index";
585
- return routePath
586
- .split("/")
587
- .filter(Boolean)
588
- .map((segment) => segment.replace(/^:/, "").replace(/\*/g, "splat"))
589
- .join("-");
590
- }
591
-
592
- function titleFromPath(routePath: string): string {
593
- if (routePath === "/") return "Home";
594
- const lastSegment = routePath.split("/").filter(Boolean).at(-1) ?? "Page";
595
- return titleCase(lastSegment.replace(/^:/, "").replace(/\*/g, "slug"));
596
- }
597
-
598
- function titleCase(value: string): string {
599
- return value
600
- .split(/[-_/]/)
601
- .filter(Boolean)
602
- .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
603
- .join(" ");
604
- }
605
-
606
- function buildStaticPathsStub(params: string[]): string {
607
- if (params.length === 0) {
608
- return "{}";
609
- }
610
-
611
- return `{ ${params.map((name) => `${name}: ${quote(`example-${name}`)}`).join(", ")} }`;
612
- }
613
-
614
- function escapeJsxText(value: string): string {
615
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
616
- }