blumenjs 0.2.0 → 0.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.
@@ -15,19 +15,22 @@
15
15
  * - index.tsx → "/" (index route for folders)
16
16
  * - NotFound.tsx → skipped (reserved 404)
17
17
  * - _app.tsx → skipped (reserved prefix)
18
+ * - _layout.tsx → layout metadata (not a route, metadata cascades to children)
18
19
  *
19
20
  * Usage: npx tsx scripts/generate-routes.ts
20
21
  */
21
22
 
22
23
  import * as fs from "fs";
23
24
  import * as path from "path";
25
+ import * as ts from "typescript";
26
+ import { generateAPIRoutes } from "./generate-api-routes";
24
27
 
25
28
  const PAGES_DIR = path.resolve("app/pages");
26
29
  const SSR_OUTPUT = path.resolve("node-ssr/generated-routes.ts");
27
30
  const CLIENT_OUTPUT = path.resolve("app/client/generated-routes.ts");
28
31
 
29
32
  // Reserved filenames that should not become routes
30
- const RESERVED = new Set(["NotFound", "NotFound.tsx"]);
33
+ const RESERVED = new Set(["NotFound", "NotFound.tsx", "loading.tsx"]);
31
34
 
32
35
  interface RouteEntry {
33
36
  /** The URL path, e.g. "/" or "/users/[id]" */
@@ -42,6 +45,81 @@ interface RouteEntry {
42
45
  keys: string[];
43
46
  /** Whether this page exports a getServerProps function */
44
47
  hasGetServerProps: boolean;
48
+ /** Whether this page exports a static metadata object */
49
+ hasMetadata: boolean;
50
+ /** Whether this page exports a generateMetadata function */
51
+ hasGenerateMetadata: boolean;
52
+ /** Whether a loading.tsx file exists in the same directory */
53
+ hasLoading: boolean;
54
+ /** Import path for the loading component (if hasLoading) */
55
+ loadingImportPath?: string;
56
+ /** Whether this page exports dynamic = 'force-static' for SSG */
57
+ isStatic: boolean;
58
+ /** Whether this page exports a getStaticProps function */
59
+ hasGetStaticProps: boolean;
60
+ /** Whether this page exports a getStaticPaths function */
61
+ hasGetStaticPaths: boolean;
62
+ /** Whether this page starts with 'use client' directive */
63
+ isClientComponent: boolean;
64
+ /** Whether this page exports runtime = 'edge' */
65
+ isEdge: boolean;
66
+ }
67
+
68
+ interface LayoutEntry {
69
+ /** Directory path relative to PAGES_DIR, e.g. "" (root) or "docs" */
70
+ dirPath: string;
71
+ /** The URL prefix this layout applies to, e.g. "/" or "/docs" */
72
+ routePrefix: string;
73
+ /** Import path for the layout file (without extension) */
74
+ importPath: string;
75
+ /** Safe identifier for imports */
76
+ layoutId: string;
77
+ /** Whether this layout exports a static metadata object */
78
+ hasMetadata: boolean;
79
+ /** Whether this layout exports a generateMetadata function */
80
+ hasGenerateMetadata: boolean;
81
+ }
82
+
83
+ /**
84
+ * Check if a file exports a named identifier at the top level.
85
+ * Uses TypeScript AST to avoid false positives from strings/comments.
86
+ */
87
+ function hasNamedExport(filePath: string, content: string, exportName: string): boolean {
88
+ const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
89
+ let found = false;
90
+
91
+ ts.forEachChild(sourceFile, (node) => {
92
+ // 1. Function declaration: export [async] function name() {}
93
+ if (ts.isFunctionDeclaration(node)) {
94
+ const isExported = node.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword);
95
+ if (isExported && node.name?.text === exportName) {
96
+ found = true;
97
+ }
98
+ }
99
+ // 2. Variable declaration: export const name = ...
100
+ else if (ts.isVariableStatement(node)) {
101
+ const isExported = node.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword);
102
+ if (isExported) {
103
+ for (const decl of node.declarationList.declarations) {
104
+ if (ts.isIdentifier(decl.name) && decl.name.text === exportName) {
105
+ found = true;
106
+ }
107
+ }
108
+ }
109
+ }
110
+ // 3. Export declaration: export { name } or export { x as name }
111
+ else if (ts.isExportDeclaration(node)) {
112
+ if (node.exportClause && ts.isNamedExports(node.exportClause)) {
113
+ for (const element of node.exportClause.elements) {
114
+ if (element.name.text === exportName) {
115
+ found = true;
116
+ }
117
+ }
118
+ }
119
+ }
120
+ });
121
+
122
+ return found;
45
123
  }
46
124
 
47
125
  function scanDir(dir: string, baseDir: string = dir): RouteEntry[] {
@@ -89,9 +167,31 @@ function scanDir(dir: string, baseDir: string = dir): RouteEntry[] {
89
167
  patternStr = `^${patternStr}$`;
90
168
  }
91
169
 
92
- // Check if the page exports getServerProps
170
+ // Check if the page exports getServerProps, metadata, or generateMetadata
93
171
  const fileContent = fs.readFileSync(fullPath, "utf-8");
94
- const hasGetServerProps = /export\s+(async\s+)?function\s+getServerProps/.test(fileContent);
172
+ const hasGetServerProps = hasNamedExport(fullPath, fileContent, "getServerProps");
173
+ const hasGetStaticProps = hasNamedExport(fullPath, fileContent, "getStaticProps");
174
+ const hasGetStaticPaths = hasNamedExport(fullPath, fileContent, "getStaticPaths");
175
+ const hasMetadata = hasNamedExport(fullPath, fileContent, "metadata");
176
+ const hasGenerateMetadata = hasNamedExport(fullPath, fileContent, "generateMetadata");
177
+ const hasDynamic = hasNamedExport(fullPath, fileContent, "dynamic");
178
+ // Pages with getStaticProps are automatically static
179
+ const isStatic = hasGetStaticProps || (hasDynamic && fileContent.includes("force-static"));
180
+
181
+ // Detect 'use client' directive at the top of the file
182
+ const isClientComponent = /^\s*['"]use client['"]/.test(fileContent);
183
+
184
+ // Detect runtime = 'edge' for edge functions
185
+ const hasRuntime = hasNamedExport(fullPath, fileContent, "runtime");
186
+ const isEdge = hasRuntime && fileContent.includes("edge");
187
+
188
+ // Check for a loading.tsx file in the same directory
189
+ const pageDir = path.dirname(fullPath);
190
+ const loadingFile = path.join(pageDir, "loading.tsx");
191
+ const hasLoading = fs.existsSync(loadingFile);
192
+ const loadingImportPath = hasLoading
193
+ ? path.relative(baseDir, loadingFile).replace(".tsx", "").replace(/\\/g, "/")
194
+ : undefined;
95
195
 
96
196
  results.push({
97
197
  route: routePath,
@@ -99,11 +199,65 @@ function scanDir(dir: string, baseDir: string = dir): RouteEntry[] {
99
199
  importPath: relPath.replace(".tsx", "").replace(/\\/g, "/"),
100
200
  patternStr,
101
201
  keys,
102
- hasGetServerProps
202
+ hasGetServerProps,
203
+ hasMetadata,
204
+ hasGenerateMetadata,
205
+ hasLoading,
206
+ loadingImportPath,
207
+ isStatic,
208
+ hasGetStaticProps,
209
+ hasGetStaticPaths,
210
+ isClientComponent,
211
+ isEdge,
212
+ });
213
+ }
214
+ }
215
+
216
+ return results;
217
+ }
218
+
219
+ /**
220
+ * Scan for _layout.tsx files in the pages directory tree.
221
+ * These files can export metadata/generateMetadata that cascades to child pages.
222
+ */
223
+ function scanLayouts(dir: string, baseDir: string = dir): LayoutEntry[] {
224
+ let results: LayoutEntry[] = [];
225
+ const items = fs.readdirSync(dir, { withFileTypes: true });
226
+
227
+ // Check for _layout.tsx in this directory
228
+ const layoutFile = path.join(dir, "_layout.tsx");
229
+ if (fs.existsSync(layoutFile)) {
230
+ const relDir = path.relative(baseDir, dir).replace(/\\/g, "/");
231
+ const routePrefix = relDir === "" ? "/" : "/" + relDir.toLowerCase();
232
+ const layoutId = relDir === "" ? "Layout_root" : "Layout_" + relDir.replace(/[^a-zA-Z0-9]/g, "_");
233
+ const importPath = relDir === "" ? "_layout" : relDir + "/_layout";
234
+
235
+ const content = fs.readFileSync(layoutFile, "utf-8");
236
+ const hasMetadata = hasNamedExport(layoutFile, content, "metadata");
237
+ const hasGenerateMetadata = hasNamedExport(layoutFile, content, "generateMetadata");
238
+
239
+ if (hasMetadata || hasGenerateMetadata) {
240
+ results.push({
241
+ dirPath: relDir,
242
+ routePrefix,
243
+ importPath,
244
+ layoutId,
245
+ hasMetadata,
246
+ hasGenerateMetadata,
103
247
  });
104
248
  }
105
249
  }
106
250
 
251
+ // Recurse into subdirectories
252
+ for (const item of items) {
253
+ if (item.isDirectory() && !item.name.startsWith("_")) {
254
+ results = results.concat(scanLayouts(path.join(dir, item.name), baseDir));
255
+ }
256
+ }
257
+
258
+ // Sort by depth (root first, then deeper segments)
259
+ results.sort((a, b) => a.dirPath.split("/").length - b.dirPath.split("/").length);
260
+
107
261
  return results;
108
262
  }
109
263
 
@@ -125,8 +279,21 @@ function discoverPages(): RouteEntry[] {
125
279
  return routes;
126
280
  }
127
281
 
282
+ /**
283
+ * Generate a webpack chunk name for a route entry.
284
+ * Produces clean names like "page-home", "page-docs", "page-users-id".
285
+ */
286
+ function getChunkName(r: RouteEntry): string {
287
+ return "page-" + r.importPath
288
+ .replace(/\/index$/, "") // "docs/index" → "docs"
289
+ .replace(/\[([^\]]+)\]/g, "$1") // "[id]" → "id"
290
+ .replace(/\//g, "-") // "dashboard/settings" → "dashboard-settings"
291
+ .toLowerCase();
292
+ }
293
+
128
294
  function generateRouteFile(
129
295
  routes: RouteEntry[],
296
+ layouts: LayoutEntry[],
130
297
  importPathPrefix: string,
131
298
  isServer: boolean,
132
299
  ): string {
@@ -140,11 +307,18 @@ function generateRouteFile(
140
307
  "",
141
308
  ];
142
309
 
143
- // Import default exports!
144
- const imports = routes.map(
145
- (r) =>
146
- `import ${r.componentId} from "${importPathPrefix}/${r.importPath}";`,
147
- );
310
+ // Import page components
311
+ // Server: static imports (SSR needs all components loaded)
312
+ // Client: React.lazy() dynamic imports for code splitting
313
+ const imports = isServer
314
+ ? routes.map(
315
+ (r) =>
316
+ `import ${r.componentId} from "${importPathPrefix}/${r.importPath}";`,
317
+ )
318
+ : routes.map((r) => {
319
+ const chunkName = getChunkName(r);
320
+ return `const ${r.componentId} = React.lazy(() => import(/* webpackChunkName: "${chunkName}" */ "${importPathPrefix}/${r.importPath}"));`;
321
+ });
148
322
 
149
323
  // For SSR: import getServerProps from pages that export it
150
324
  const gspImports: string[] = [];
@@ -158,6 +332,69 @@ function generateRouteFile(
158
332
  }
159
333
  }
160
334
 
335
+ // For SSR: import getStaticProps from pages that export it
336
+ // getStaticProps works like getServerProps but the page is pre-rendered at build time
337
+ const staticPropsImports: string[] = [];
338
+ const staticPropsRoutes = routes.filter(r => r.hasGetStaticProps);
339
+ if (isServer && staticPropsRoutes.length > 0) {
340
+ for (const r of staticPropsRoutes) {
341
+ const spId = "staticProps_" + r.componentId.replace("Page_", "");
342
+ staticPropsImports.push(
343
+ `import { getStaticProps as ${spId} } from "${importPathPrefix}/${r.importPath}";`,
344
+ );
345
+ }
346
+ }
347
+
348
+ // For SSR: import getStaticPaths from pages that export it (dynamic SSG)
349
+ const staticPathsImports: string[] = [];
350
+ const staticPathsRoutes = routes.filter(r => r.hasGetStaticPaths);
351
+ if (isServer && staticPathsRoutes.length > 0) {
352
+ for (const r of staticPathsRoutes) {
353
+ const pathsId = "staticPaths_" + r.componentId.replace("Page_", "");
354
+ staticPathsImports.push(
355
+ `import { getStaticPaths as ${pathsId} } from "${importPathPrefix}/${r.importPath}";`,
356
+ );
357
+ }
358
+ }
359
+
360
+ // For SSR: import metadata and generateMetadata from pages that export them
361
+ const metaImports: string[] = [];
362
+ const metaRoutes = routes.filter(r => r.hasMetadata);
363
+ const genMetaRoutes = routes.filter(r => r.hasGenerateMetadata);
364
+ if (isServer) {
365
+ for (const r of metaRoutes) {
366
+ const metaId = "meta_" + r.componentId.replace("Page_", "");
367
+ metaImports.push(
368
+ `import { metadata as ${metaId} } from "${importPathPrefix}/${r.importPath}";`,
369
+ );
370
+ }
371
+ for (const r of genMetaRoutes) {
372
+ const genMetaId = "genMeta_" + r.componentId.replace("Page_", "");
373
+ metaImports.push(
374
+ `import { generateMetadata as ${genMetaId} } from "${importPathPrefix}/${r.importPath}";`,
375
+ );
376
+ }
377
+ }
378
+
379
+ // For SSR: import metadata and generateMetadata from layout files
380
+ const layoutImports: string[] = [];
381
+ if (isServer) {
382
+ for (const l of layouts) {
383
+ if (l.hasMetadata) {
384
+ const metaId = "layoutMeta_" + l.layoutId;
385
+ layoutImports.push(
386
+ `import { metadata as ${metaId} } from "${importPathPrefix}/${l.importPath}";`,
387
+ );
388
+ }
389
+ if (l.hasGenerateMetadata) {
390
+ const genMetaId = "layoutGenMeta_" + l.layoutId;
391
+ layoutImports.push(
392
+ `import { generateMetadata as ${genMetaId} } from "${importPathPrefix}/${l.importPath}";`,
393
+ );
394
+ }
395
+ }
396
+ }
397
+
161
398
  const hasApp = fs.existsSync(path.join(PAGES_DIR, "_app.tsx"));
162
399
  const hasDoc = fs.existsSync(path.join(PAGES_DIR, "_document.tsx"));
163
400
 
@@ -172,35 +409,182 @@ function generateRouteFile(
172
409
  : `import { DefaultDocument } from "${importPathPrefix}/../shared/DefaultDocument";\nexport const Document = DefaultDocument;`;
173
410
  }
174
411
 
412
+ // Import loading components (client-side only)
413
+ const loadingImports: string[] = [];
414
+ const loadingRoutes = routes.filter(r => r.hasLoading);
415
+ if (!isServer && loadingRoutes.length > 0) {
416
+ for (const r of loadingRoutes) {
417
+ const loadingId = "Loading_" + r.componentId.replace("Page_", "");
418
+ loadingImports.push(
419
+ `import ${loadingId} from "${importPathPrefix}/${r.loadingImportPath}";`,
420
+ );
421
+ }
422
+ }
423
+
175
424
  const routeObjects = routes.map(r => {
176
425
  const keysArray = r.keys.length > 0 ? `["${r.keys.join('", "')}"]` : "[]";
426
+ const loadingId = r.hasLoading ? "Loading_" + r.componentId.replace("Page_", "") : null;
427
+ const loadingLine = !isServer && loadingId ? `,\n\t\tloading: ${loadingId}` : "";
177
428
  return `\t{
178
429
  \t\tpath: "${r.route}",
179
430
  \t\tpattern: new RegExp("${r.patternStr.replace(/\\/g, '\\\\')}"),
180
431
  \t\tkeys: ${keysArray},
181
- \t\tcomponent: ${r.componentId}
432
+ \t\tcomponent: ${r.componentId}${loadingLine}
182
433
  \t}`;
183
434
  });
184
435
 
185
436
  // Generate serverPropsMap for SSR routes
186
437
  let serverPropsMapStr = "";
187
- if (isServer && gspRoutes.length > 0) {
438
+ if (isServer && (gspRoutes.length > 0 || staticPropsRoutes.length > 0)) {
439
+ // Merge getServerProps + getStaticProps into one map
440
+ // getStaticProps pages are treated identically at render time
188
441
  const entries = gspRoutes.map(r => {
189
442
  const gspId = "gsp_" + r.componentId.replace("Page_", "");
190
443
  return `\t"${r.route}": ${gspId},`;
191
444
  });
445
+ const staticEntries = staticPropsRoutes.map(r => {
446
+ const spId = "staticProps_" + r.componentId.replace("Page_", "");
447
+ return `\t"${r.route}": ${spId},`;
448
+ });
192
449
  serverPropsMapStr = [
193
450
  "",
194
- "// Map of routes that export getServerProps",
451
+ "// Map of routes that export getServerProps or getStaticProps",
195
452
  "// Used by the SSR server to run data fetching before rendering",
196
453
  "export const serverPropsMap: Record<string, Function> = {",
197
454
  ...entries,
455
+ ...staticEntries,
198
456
  "};",
199
457
  ].join("\n");
200
458
  } else if (isServer) {
201
459
  serverPropsMapStr = "\nexport const serverPropsMap: Record<string, Function> = {};";
202
460
  }
203
461
 
462
+ // Generate staticPropsMap (for ISR — tracks which pages use getStaticProps)
463
+ let staticMapsStr = "";
464
+ if (isServer) {
465
+ if (staticPropsRoutes.length > 0) {
466
+ const entries = staticPropsRoutes.map(r => {
467
+ const spId = "staticProps_" + r.componentId.replace("Page_", "");
468
+ return `\t"${r.route}": ${spId},`;
469
+ });
470
+ staticMapsStr += [
471
+ "",
472
+ "// Map of routes with getStaticProps (for ISR revalidation)",
473
+ "export const staticPropsMap: Record<string, Function> = {",
474
+ ...entries,
475
+ "};",
476
+ ].join("\n");
477
+ } else {
478
+ staticMapsStr += "\nexport const staticPropsMap: Record<string, Function> = {};";
479
+ }
480
+
481
+ if (staticPathsRoutes.length > 0) {
482
+ const entries = staticPathsRoutes.map(r => {
483
+ const pathsId = "staticPaths_" + r.componentId.replace("Page_", "");
484
+ return `\t"${r.route}": ${pathsId},`;
485
+ });
486
+ staticMapsStr += [
487
+ "",
488
+ "// Map of routes with getStaticPaths (for dynamic SSG)",
489
+ "export const staticPathsMap: Record<string, Function> = {",
490
+ ...entries,
491
+ "};",
492
+ ].join("\n");
493
+ } else {
494
+ staticMapsStr += "\nexport const staticPathsMap: Record<string, Function> = {};";
495
+ }
496
+ }
497
+
498
+ // Generate metadataMap and generateMetadataMap for SSR routes
499
+ let metadataMapStr = "";
500
+ if (isServer) {
501
+ // Static metadata map
502
+ if (metaRoutes.length > 0) {
503
+ const entries = metaRoutes.map(r => {
504
+ const metaId = "meta_" + r.componentId.replace("Page_", "");
505
+ return `\t"${r.route}": ${metaId},`;
506
+ });
507
+ metadataMapStr += [
508
+ "",
509
+ "// Map of routes with static metadata exports",
510
+ "export const metadataMap: Record<string, any> = {",
511
+ ...entries,
512
+ "};",
513
+ ].join("\n");
514
+ } else {
515
+ metadataMapStr += "\nexport const metadataMap: Record<string, any> = {};";
516
+ }
517
+
518
+ // Dynamic generateMetadata map
519
+ if (genMetaRoutes.length > 0) {
520
+ const entries = genMetaRoutes.map(r => {
521
+ const genMetaId = "genMeta_" + r.componentId.replace("Page_", "");
522
+ return `\t"${r.route}": ${genMetaId},`;
523
+ });
524
+ metadataMapStr += [
525
+ "",
526
+ "// Map of routes with dynamic generateMetadata exports",
527
+ "export const generateMetadataMap: Record<string, Function> = {",
528
+ ...entries,
529
+ "};",
530
+ ].join("\n");
531
+ } else {
532
+ metadataMapStr += "\nexport const generateMetadataMap: Record<string, Function> = {};";
533
+ }
534
+
535
+ // Layout static metadata map
536
+ const layoutMetaEntries = layouts.filter(l => l.hasMetadata);
537
+ if (layoutMetaEntries.length > 0) {
538
+ const entries = layoutMetaEntries.map(l => {
539
+ const metaId = "layoutMeta_" + l.layoutId;
540
+ return `\t"${l.routePrefix}": ${metaId},`;
541
+ });
542
+ metadataMapStr += [
543
+ "",
544
+ "// Layout metadata — cascades to all child pages in the directory",
545
+ "export const layoutMetadataMap: Record<string, any> = {",
546
+ ...entries,
547
+ "};",
548
+ ].join("\n");
549
+ } else {
550
+ metadataMapStr += "\nexport const layoutMetadataMap: Record<string, any> = {};";
551
+ }
552
+
553
+ // Layout dynamic generateMetadata map
554
+ const layoutGenMetaEntries = layouts.filter(l => l.hasGenerateMetadata);
555
+ if (layoutGenMetaEntries.length > 0) {
556
+ const entries = layoutGenMetaEntries.map(l => {
557
+ const genMetaId = "layoutGenMeta_" + l.layoutId;
558
+ return `\t"${l.routePrefix}": ${genMetaId},`;
559
+ });
560
+ metadataMapStr += [
561
+ "",
562
+ "// Layout dynamic metadata — cascades to all child pages",
563
+ "export const layoutGenerateMetadataMap: Record<string, Function> = {",
564
+ ...entries,
565
+ "};",
566
+ ].join("\n");
567
+ } else {
568
+ metadataMapStr += "\nexport const layoutGenerateMetadataMap: Record<string, Function> = {};";
569
+ }
570
+ }
571
+
572
+ // Generate route-to-chunk name map (client-side only, for prefetching)
573
+ let routeChunkMapStr = "";
574
+ if (!isServer) {
575
+ const entries = routes.map(r => {
576
+ const chunkName = getChunkName(r);
577
+ return `\t"${r.route}": "${chunkName}",`;
578
+ });
579
+ routeChunkMapStr = [
580
+ "",
581
+ "// Map of route paths to their webpack chunk names (for prefetching)",
582
+ "export const routeChunkMap: Record<string, string> = {",
583
+ ...entries,
584
+ "};",
585
+ ].join("\n");
586
+ }
587
+
204
588
  const map = [
205
589
  "",
206
590
  "export interface RouteDef {",
@@ -208,6 +592,7 @@ function generateRouteFile(
208
592
  "\tpattern: RegExp;",
209
593
  "\tkeys: string[];",
210
594
  "\tcomponent: React.ComponentType<any>;",
595
+ "\tloading?: React.ComponentType<any>;",
211
596
  "}",
212
597
  "",
213
598
  "export const routes: RouteDef[] = [",
@@ -217,10 +602,13 @@ function generateRouteFile(
217
602
  appImport,
218
603
  docImport,
219
604
  serverPropsMapStr,
605
+ staticMapsStr,
606
+ metadataMapStr,
607
+ routeChunkMapStr,
220
608
  "",
221
609
  ];
222
610
 
223
- return [...header, ...imports, ...gspImports, ...map].join("\n");
611
+ return [...header, ...imports, ...gspImports, ...staticPropsImports, ...staticPathsImports, ...metaImports, ...layoutImports, ...loadingImports, ...map].join("\n");
224
612
  }
225
613
 
226
614
  function main() {
@@ -228,32 +616,84 @@ function main() {
228
616
  console.log(` Scanning: ${PAGES_DIR}`);
229
617
 
230
618
  const routes = discoverPages();
619
+ const layouts = scanLayouts(PAGES_DIR);
231
620
 
232
621
  if (routes.length === 0) {
233
622
  console.error(" ❌ No pages found!");
234
623
  process.exit(1);
235
624
  }
236
625
 
626
+ // Print detected layouts
627
+ if (layouts.length > 0) {
628
+ console.log(` Found ${layouts.length} layout(s) with metadata:`);
629
+ for (const l of layouts) {
630
+ const tags: string[] = [];
631
+ if (l.hasMetadata) tags.push("metadata");
632
+ if (l.hasGenerateMetadata) tags.push("generateMetadata");
633
+ const scope = l.routePrefix === "/" ? "(all pages)" : `(${l.routePrefix}/*)` ;
634
+ console.log(` 📐 ${l.importPath}.tsx [${tags.join(", ")}] ${scope}`);
635
+ }
636
+ }
637
+
237
638
  console.log(` Found ${routes.length} route(s):`);
238
639
  for (const r of routes) {
239
- const gspTag = r.hasGetServerProps ? " [getServerProps]" : "";
240
- console.log(` ${r.route.padEnd(25)} → ${r.importPath}.tsx${gspTag}`);
640
+ const tags: string[] = [];
641
+ if (r.hasGetServerProps) tags.push("getServerProps");
642
+ if (r.hasGetStaticProps) tags.push("getStaticProps");
643
+ if (r.hasMetadata) tags.push("metadata");
644
+ if (r.hasGenerateMetadata) tags.push("generateMetadata");
645
+ if (r.hasLoading) tags.push("loading");
646
+ if (r.isStatic) tags.push("ssg");
647
+ if (r.isClientComponent) tags.push("use client");
648
+ if (r.isEdge) tags.push("edge");
649
+ const tagStr = tags.length > 0 ? ` [${tags.join(", ")}]` : "";
650
+ console.log(` ${r.route.padEnd(25)} → ${r.importPath}.tsx${tagStr}`);
241
651
  }
242
652
 
243
653
  // Generate SSR routes (node-ssr/ → ../app/pages)
244
- const ssrContent = generateRouteFile(routes, "../app/pages", true);
654
+ const ssrContent = generateRouteFile(routes, layouts, "../app/pages", true);
245
655
  fs.mkdirSync(path.dirname(SSR_OUTPUT), { recursive: true });
246
656
  fs.writeFileSync(SSR_OUTPUT, ssrContent, "utf-8");
247
657
  console.log(` ✅ Written: ${path.relative(process.cwd(), SSR_OUTPUT)}`);
248
658
 
249
659
  // Generate Client routes (app/client/ → ../pages)
250
- const clientContent = generateRouteFile(routes, "../pages", false);
660
+ const clientContent = generateRouteFile(routes, layouts, "../pages", false);
251
661
  fs.mkdirSync(path.dirname(CLIENT_OUTPUT), { recursive: true });
252
662
  fs.writeFileSync(CLIENT_OUTPUT, clientContent, "utf-8");
253
663
  console.log(
254
664
  ` ✅ Written: ${path.relative(process.cwd(), CLIENT_OUTPUT)}`,
255
665
  );
256
666
 
667
+ // Generate API routes (app/api/ → node-ssr/generated-api-routes.ts)
668
+ console.log(" → Scanning API routes...");
669
+ const apiRoutes = generateAPIRoutes();
670
+ if (apiRoutes.length > 0) {
671
+ console.log(` Found ${apiRoutes.length} API route(s):`);
672
+ for (const r of apiRoutes) {
673
+ console.log(` ${r.methods.join("|").padEnd(20)} ${r.route}`);
674
+ }
675
+ console.log(` ✅ Written: node-ssr/generated-api-routes.ts`);
676
+ } else {
677
+ console.log(" No API routes found (app/api/ is empty or missing)");
678
+ }
679
+
680
+ // Write SSG manifest for the build step
681
+ const ssgRoutes = routes.filter(r => r.isStatic);
682
+ if (ssgRoutes.length > 0) {
683
+ const ssgManifest = {
684
+ pages: ssgRoutes.map(r => ({
685
+ route: r.route,
686
+ componentId: r.componentId,
687
+ hasGetServerProps: r.hasGetServerProps,
688
+ hasGetStaticProps: r.hasGetStaticProps,
689
+ hasGetStaticPaths: r.hasGetStaticPaths,
690
+ }))
691
+ };
692
+ fs.mkdirSync("dist", { recursive: true });
693
+ fs.writeFileSync("dist/ssg-manifest.json", JSON.stringify(ssgManifest, null, 2));
694
+ console.log(` 📄 SSG: ${ssgRoutes.length} page(s) will be pre-rendered at build time`);
695
+ }
696
+
257
697
  console.log(" 🎉 Routes generated successfully!");
258
698
  }
259
699
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blumenjs",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "The React framework powered by Go. Lightning-fast SSR with the DX you deserve.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,7 +29,10 @@
29
29
  "dev:go": "go run go-server/main.go go-server/image.go go-server/cache.go",
30
30
  "build:client": "webpack --mode production",
31
31
  "build:ssr": "esbuild node-ssr/server.ts --bundle --platform=node --format=esm --outfile=dist/ssr-server.js --packages=external",
32
- "clean": "rm -rf dist static/js/bundle.js"
32
+ "clean": "rm -rf dist static/js/bundle.js",
33
+ "test": "tsx cli/blumen.ts test",
34
+ "test:coverage": "tsx cli/blumen.ts test --coverage",
35
+ "bench": "tsx cli/blumen.ts bench"
33
36
  },
34
37
  "keywords": [
35
38
  "react",
@@ -49,9 +52,11 @@
49
52
  },
50
53
  "homepage": "",
51
54
  "dependencies": {
52
- "lucide-react": "^1.14.0",
53
- "react": "^18.2.0",
54
- "react-dom": "^18.2.0"
55
+ "lucide-react": "^1.14.0"
56
+ },
57
+ "peerDependencies": {
58
+ "react": "^18.2.0 || ^19.0.0",
59
+ "react-dom": "^18.2.0 || ^19.0.0"
55
60
  },
56
61
  "devDependencies": {
57
62
  "@babel/core": "^7.29.0",
@@ -59,19 +64,28 @@
59
64
  "@babel/preset-react": "^7.28.5",
60
65
  "@babel/preset-typescript": "^7.28.5",
61
66
  "@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
67
+ "@testing-library/dom": "^10.4.1",
68
+ "@testing-library/jest-dom": "^6.9.1",
69
+ "@testing-library/react": "^16.3.2",
62
70
  "@types/node": "^20.10.0",
63
- "@types/react": "^18.2.0",
64
- "@types/react-dom": "^18.2.0",
71
+ "@types/react": "^18.3.29",
72
+ "@types/react-dom": "^18.3.7",
73
+ "@vitejs/plugin-react": "^6.0.2",
74
+ "@vitest/coverage-v8": "^4.1.7",
65
75
  "babel-loader": "^10.1.1",
66
76
  "concurrently": "^8.2.2",
67
77
  "css-loader": "^7.1.4",
68
78
  "esbuild": "^0.19.0",
79
+ "jsdom": "^29.1.1",
69
80
  "mini-css-extract-plugin": "^2.10.2",
81
+ "react": "^19.2.6",
82
+ "react-dom": "^19.2.6",
70
83
  "react-refresh": "^0.18.0",
71
84
  "style-loader": "^4.0.0",
72
85
  "ts-loader": "^9.5.1",
73
86
  "tsx": "^4.6.0",
74
87
  "typescript": "^5.3.0",
88
+ "vitest": "^4.1.7",
75
89
  "webpack": "^5.89.0",
76
90
  "webpack-bundle-analyzer": "^5.3.0",
77
91
  "webpack-cli": "^5.1.4",