blumenjs 0.2.0 → 0.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.
@@ -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,73 @@ 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
+ }
59
+
60
+ interface LayoutEntry {
61
+ /** Directory path relative to PAGES_DIR, e.g. "" (root) or "docs" */
62
+ dirPath: string;
63
+ /** The URL prefix this layout applies to, e.g. "/" or "/docs" */
64
+ routePrefix: string;
65
+ /** Import path for the layout file (without extension) */
66
+ importPath: string;
67
+ /** Safe identifier for imports */
68
+ layoutId: string;
69
+ /** Whether this layout exports a static metadata object */
70
+ hasMetadata: boolean;
71
+ /** Whether this layout exports a generateMetadata function */
72
+ hasGenerateMetadata: boolean;
73
+ }
74
+
75
+ /**
76
+ * Check if a file exports a named identifier at the top level.
77
+ * Uses TypeScript AST to avoid false positives from strings/comments.
78
+ */
79
+ function hasNamedExport(filePath: string, content: string, exportName: string): boolean {
80
+ const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
81
+ let found = false;
82
+
83
+ ts.forEachChild(sourceFile, (node) => {
84
+ // 1. Function declaration: export [async] function name() {}
85
+ if (ts.isFunctionDeclaration(node)) {
86
+ const isExported = node.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword);
87
+ if (isExported && node.name?.text === exportName) {
88
+ found = true;
89
+ }
90
+ }
91
+ // 2. Variable declaration: export const name = ...
92
+ else if (ts.isVariableStatement(node)) {
93
+ const isExported = node.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword);
94
+ if (isExported) {
95
+ for (const decl of node.declarationList.declarations) {
96
+ if (ts.isIdentifier(decl.name) && decl.name.text === exportName) {
97
+ found = true;
98
+ }
99
+ }
100
+ }
101
+ }
102
+ // 3. Export declaration: export { name } or export { x as name }
103
+ else if (ts.isExportDeclaration(node)) {
104
+ if (node.exportClause && ts.isNamedExports(node.exportClause)) {
105
+ for (const element of node.exportClause.elements) {
106
+ if (element.name.text === exportName) {
107
+ found = true;
108
+ }
109
+ }
110
+ }
111
+ }
112
+ });
113
+
114
+ return found;
45
115
  }
46
116
 
47
117
  function scanDir(dir: string, baseDir: string = dir): RouteEntry[] {
@@ -89,9 +159,21 @@ function scanDir(dir: string, baseDir: string = dir): RouteEntry[] {
89
159
  patternStr = `^${patternStr}$`;
90
160
  }
91
161
 
92
- // Check if the page exports getServerProps
162
+ // Check if the page exports getServerProps, metadata, or generateMetadata
93
163
  const fileContent = fs.readFileSync(fullPath, "utf-8");
94
- const hasGetServerProps = /export\s+(async\s+)?function\s+getServerProps/.test(fileContent);
164
+ const hasGetServerProps = hasNamedExport(fullPath, fileContent, "getServerProps");
165
+ const hasMetadata = hasNamedExport(fullPath, fileContent, "metadata");
166
+ const hasGenerateMetadata = hasNamedExport(fullPath, fileContent, "generateMetadata");
167
+ const hasDynamic = hasNamedExport(fullPath, fileContent, "dynamic");
168
+ const isStatic = hasDynamic && fileContent.includes("force-static");
169
+
170
+ // Check for a loading.tsx file in the same directory
171
+ const pageDir = path.dirname(fullPath);
172
+ const loadingFile = path.join(pageDir, "loading.tsx");
173
+ const hasLoading = fs.existsSync(loadingFile);
174
+ const loadingImportPath = hasLoading
175
+ ? path.relative(baseDir, loadingFile).replace(".tsx", "").replace(/\\/g, "/")
176
+ : undefined;
95
177
 
96
178
  results.push({
97
179
  route: routePath,
@@ -99,11 +181,61 @@ function scanDir(dir: string, baseDir: string = dir): RouteEntry[] {
99
181
  importPath: relPath.replace(".tsx", "").replace(/\\/g, "/"),
100
182
  patternStr,
101
183
  keys,
102
- hasGetServerProps
184
+ hasGetServerProps,
185
+ hasMetadata,
186
+ hasGenerateMetadata,
187
+ hasLoading,
188
+ loadingImportPath,
189
+ isStatic,
190
+ });
191
+ }
192
+ }
193
+
194
+ return results;
195
+ }
196
+
197
+ /**
198
+ * Scan for _layout.tsx files in the pages directory tree.
199
+ * These files can export metadata/generateMetadata that cascades to child pages.
200
+ */
201
+ function scanLayouts(dir: string, baseDir: string = dir): LayoutEntry[] {
202
+ let results: LayoutEntry[] = [];
203
+ const items = fs.readdirSync(dir, { withFileTypes: true });
204
+
205
+ // Check for _layout.tsx in this directory
206
+ const layoutFile = path.join(dir, "_layout.tsx");
207
+ if (fs.existsSync(layoutFile)) {
208
+ const relDir = path.relative(baseDir, dir).replace(/\\/g, "/");
209
+ const routePrefix = relDir === "" ? "/" : "/" + relDir.toLowerCase();
210
+ const layoutId = relDir === "" ? "Layout_root" : "Layout_" + relDir.replace(/[^a-zA-Z0-9]/g, "_");
211
+ const importPath = relDir === "" ? "_layout" : relDir + "/_layout";
212
+
213
+ const content = fs.readFileSync(layoutFile, "utf-8");
214
+ const hasMetadata = hasNamedExport(layoutFile, content, "metadata");
215
+ const hasGenerateMetadata = hasNamedExport(layoutFile, content, "generateMetadata");
216
+
217
+ if (hasMetadata || hasGenerateMetadata) {
218
+ results.push({
219
+ dirPath: relDir,
220
+ routePrefix,
221
+ importPath,
222
+ layoutId,
223
+ hasMetadata,
224
+ hasGenerateMetadata,
103
225
  });
104
226
  }
105
227
  }
106
228
 
229
+ // Recurse into subdirectories
230
+ for (const item of items) {
231
+ if (item.isDirectory() && !item.name.startsWith("_")) {
232
+ results = results.concat(scanLayouts(path.join(dir, item.name), baseDir));
233
+ }
234
+ }
235
+
236
+ // Sort by depth (root first, then deeper segments)
237
+ results.sort((a, b) => a.dirPath.split("/").length - b.dirPath.split("/").length);
238
+
107
239
  return results;
108
240
  }
109
241
 
@@ -127,6 +259,7 @@ function discoverPages(): RouteEntry[] {
127
259
 
128
260
  function generateRouteFile(
129
261
  routes: RouteEntry[],
262
+ layouts: LayoutEntry[],
130
263
  importPathPrefix: string,
131
264
  isServer: boolean,
132
265
  ): string {
@@ -158,6 +291,44 @@ function generateRouteFile(
158
291
  }
159
292
  }
160
293
 
294
+ // For SSR: import metadata and generateMetadata from pages that export them
295
+ const metaImports: string[] = [];
296
+ const metaRoutes = routes.filter(r => r.hasMetadata);
297
+ const genMetaRoutes = routes.filter(r => r.hasGenerateMetadata);
298
+ if (isServer) {
299
+ for (const r of metaRoutes) {
300
+ const metaId = "meta_" + r.componentId.replace("Page_", "");
301
+ metaImports.push(
302
+ `import { metadata as ${metaId} } from "${importPathPrefix}/${r.importPath}";`,
303
+ );
304
+ }
305
+ for (const r of genMetaRoutes) {
306
+ const genMetaId = "genMeta_" + r.componentId.replace("Page_", "");
307
+ metaImports.push(
308
+ `import { generateMetadata as ${genMetaId} } from "${importPathPrefix}/${r.importPath}";`,
309
+ );
310
+ }
311
+ }
312
+
313
+ // For SSR: import metadata and generateMetadata from layout files
314
+ const layoutImports: string[] = [];
315
+ if (isServer) {
316
+ for (const l of layouts) {
317
+ if (l.hasMetadata) {
318
+ const metaId = "layoutMeta_" + l.layoutId;
319
+ layoutImports.push(
320
+ `import { metadata as ${metaId} } from "${importPathPrefix}/${l.importPath}";`,
321
+ );
322
+ }
323
+ if (l.hasGenerateMetadata) {
324
+ const genMetaId = "layoutGenMeta_" + l.layoutId;
325
+ layoutImports.push(
326
+ `import { generateMetadata as ${genMetaId} } from "${importPathPrefix}/${l.importPath}";`,
327
+ );
328
+ }
329
+ }
330
+ }
331
+
161
332
  const hasApp = fs.existsSync(path.join(PAGES_DIR, "_app.tsx"));
162
333
  const hasDoc = fs.existsSync(path.join(PAGES_DIR, "_document.tsx"));
163
334
 
@@ -172,13 +343,27 @@ function generateRouteFile(
172
343
  : `import { DefaultDocument } from "${importPathPrefix}/../shared/DefaultDocument";\nexport const Document = DefaultDocument;`;
173
344
  }
174
345
 
346
+ // Import loading components (client-side only)
347
+ const loadingImports: string[] = [];
348
+ const loadingRoutes = routes.filter(r => r.hasLoading);
349
+ if (!isServer && loadingRoutes.length > 0) {
350
+ for (const r of loadingRoutes) {
351
+ const loadingId = "Loading_" + r.componentId.replace("Page_", "");
352
+ loadingImports.push(
353
+ `import ${loadingId} from "${importPathPrefix}/${r.loadingImportPath}";`,
354
+ );
355
+ }
356
+ }
357
+
175
358
  const routeObjects = routes.map(r => {
176
359
  const keysArray = r.keys.length > 0 ? `["${r.keys.join('", "')}"]` : "[]";
360
+ const loadingId = r.hasLoading ? "Loading_" + r.componentId.replace("Page_", "") : null;
361
+ const loadingLine = !isServer && loadingId ? `,\n\t\tloading: ${loadingId}` : "";
177
362
  return `\t{
178
363
  \t\tpath: "${r.route}",
179
364
  \t\tpattern: new RegExp("${r.patternStr.replace(/\\/g, '\\\\')}"),
180
365
  \t\tkeys: ${keysArray},
181
- \t\tcomponent: ${r.componentId}
366
+ \t\tcomponent: ${r.componentId}${loadingLine}
182
367
  \t}`;
183
368
  });
184
369
 
@@ -201,6 +386,80 @@ function generateRouteFile(
201
386
  serverPropsMapStr = "\nexport const serverPropsMap: Record<string, Function> = {};";
202
387
  }
203
388
 
389
+ // Generate metadataMap and generateMetadataMap for SSR routes
390
+ let metadataMapStr = "";
391
+ if (isServer) {
392
+ // Static metadata map
393
+ if (metaRoutes.length > 0) {
394
+ const entries = metaRoutes.map(r => {
395
+ const metaId = "meta_" + r.componentId.replace("Page_", "");
396
+ return `\t"${r.route}": ${metaId},`;
397
+ });
398
+ metadataMapStr += [
399
+ "",
400
+ "// Map of routes with static metadata exports",
401
+ "export const metadataMap: Record<string, any> = {",
402
+ ...entries,
403
+ "};",
404
+ ].join("\n");
405
+ } else {
406
+ metadataMapStr += "\nexport const metadataMap: Record<string, any> = {};";
407
+ }
408
+
409
+ // Dynamic generateMetadata map
410
+ if (genMetaRoutes.length > 0) {
411
+ const entries = genMetaRoutes.map(r => {
412
+ const genMetaId = "genMeta_" + r.componentId.replace("Page_", "");
413
+ return `\t"${r.route}": ${genMetaId},`;
414
+ });
415
+ metadataMapStr += [
416
+ "",
417
+ "// Map of routes with dynamic generateMetadata exports",
418
+ "export const generateMetadataMap: Record<string, Function> = {",
419
+ ...entries,
420
+ "};",
421
+ ].join("\n");
422
+ } else {
423
+ metadataMapStr += "\nexport const generateMetadataMap: Record<string, Function> = {};";
424
+ }
425
+
426
+ // Layout static metadata map
427
+ const layoutMetaEntries = layouts.filter(l => l.hasMetadata);
428
+ if (layoutMetaEntries.length > 0) {
429
+ const entries = layoutMetaEntries.map(l => {
430
+ const metaId = "layoutMeta_" + l.layoutId;
431
+ return `\t"${l.routePrefix}": ${metaId},`;
432
+ });
433
+ metadataMapStr += [
434
+ "",
435
+ "// Layout metadata — cascades to all child pages in the directory",
436
+ "export const layoutMetadataMap: Record<string, any> = {",
437
+ ...entries,
438
+ "};",
439
+ ].join("\n");
440
+ } else {
441
+ metadataMapStr += "\nexport const layoutMetadataMap: Record<string, any> = {};";
442
+ }
443
+
444
+ // Layout dynamic generateMetadata map
445
+ const layoutGenMetaEntries = layouts.filter(l => l.hasGenerateMetadata);
446
+ if (layoutGenMetaEntries.length > 0) {
447
+ const entries = layoutGenMetaEntries.map(l => {
448
+ const genMetaId = "layoutGenMeta_" + l.layoutId;
449
+ return `\t"${l.routePrefix}": ${genMetaId},`;
450
+ });
451
+ metadataMapStr += [
452
+ "",
453
+ "// Layout dynamic metadata — cascades to all child pages",
454
+ "export const layoutGenerateMetadataMap: Record<string, Function> = {",
455
+ ...entries,
456
+ "};",
457
+ ].join("\n");
458
+ } else {
459
+ metadataMapStr += "\nexport const layoutGenerateMetadataMap: Record<string, Function> = {};";
460
+ }
461
+ }
462
+
204
463
  const map = [
205
464
  "",
206
465
  "export interface RouteDef {",
@@ -208,6 +467,7 @@ function generateRouteFile(
208
467
  "\tpattern: RegExp;",
209
468
  "\tkeys: string[];",
210
469
  "\tcomponent: React.ComponentType<any>;",
470
+ "\tloading?: React.ComponentType<any>;",
211
471
  "}",
212
472
  "",
213
473
  "export const routes: RouteDef[] = [",
@@ -217,10 +477,11 @@ function generateRouteFile(
217
477
  appImport,
218
478
  docImport,
219
479
  serverPropsMapStr,
480
+ metadataMapStr,
220
481
  "",
221
482
  ];
222
483
 
223
- return [...header, ...imports, ...gspImports, ...map].join("\n");
484
+ return [...header, ...imports, ...gspImports, ...metaImports, ...layoutImports, ...loadingImports, ...map].join("\n");
224
485
  }
225
486
 
226
487
  function main() {
@@ -228,32 +489,79 @@ function main() {
228
489
  console.log(` Scanning: ${PAGES_DIR}`);
229
490
 
230
491
  const routes = discoverPages();
492
+ const layouts = scanLayouts(PAGES_DIR);
231
493
 
232
494
  if (routes.length === 0) {
233
495
  console.error(" ❌ No pages found!");
234
496
  process.exit(1);
235
497
  }
236
498
 
499
+ // Print detected layouts
500
+ if (layouts.length > 0) {
501
+ console.log(` Found ${layouts.length} layout(s) with metadata:`);
502
+ for (const l of layouts) {
503
+ const tags: string[] = [];
504
+ if (l.hasMetadata) tags.push("metadata");
505
+ if (l.hasGenerateMetadata) tags.push("generateMetadata");
506
+ const scope = l.routePrefix === "/" ? "(all pages)" : `(${l.routePrefix}/*)` ;
507
+ console.log(` 📐 ${l.importPath}.tsx [${tags.join(", ")}] ${scope}`);
508
+ }
509
+ }
510
+
237
511
  console.log(` Found ${routes.length} route(s):`);
238
512
  for (const r of routes) {
239
- const gspTag = r.hasGetServerProps ? " [getServerProps]" : "";
240
- console.log(` ${r.route.padEnd(25)} → ${r.importPath}.tsx${gspTag}`);
513
+ const tags: string[] = [];
514
+ if (r.hasGetServerProps) tags.push("getServerProps");
515
+ if (r.hasMetadata) tags.push("metadata");
516
+ if (r.hasGenerateMetadata) tags.push("generateMetadata");
517
+ if (r.hasLoading) tags.push("loading");
518
+ if (r.isStatic) tags.push("ssg");
519
+ const tagStr = tags.length > 0 ? ` [${tags.join(", ")}]` : "";
520
+ console.log(` ${r.route.padEnd(25)} → ${r.importPath}.tsx${tagStr}`);
241
521
  }
242
522
 
243
523
  // Generate SSR routes (node-ssr/ → ../app/pages)
244
- const ssrContent = generateRouteFile(routes, "../app/pages", true);
524
+ const ssrContent = generateRouteFile(routes, layouts, "../app/pages", true);
245
525
  fs.mkdirSync(path.dirname(SSR_OUTPUT), { recursive: true });
246
526
  fs.writeFileSync(SSR_OUTPUT, ssrContent, "utf-8");
247
527
  console.log(` ✅ Written: ${path.relative(process.cwd(), SSR_OUTPUT)}`);
248
528
 
249
529
  // Generate Client routes (app/client/ → ../pages)
250
- const clientContent = generateRouteFile(routes, "../pages", false);
530
+ const clientContent = generateRouteFile(routes, layouts, "../pages", false);
251
531
  fs.mkdirSync(path.dirname(CLIENT_OUTPUT), { recursive: true });
252
532
  fs.writeFileSync(CLIENT_OUTPUT, clientContent, "utf-8");
253
533
  console.log(
254
534
  ` ✅ Written: ${path.relative(process.cwd(), CLIENT_OUTPUT)}`,
255
535
  );
256
536
 
537
+ // Generate API routes (app/api/ → node-ssr/generated-api-routes.ts)
538
+ console.log(" → Scanning API routes...");
539
+ const apiRoutes = generateAPIRoutes();
540
+ if (apiRoutes.length > 0) {
541
+ console.log(` Found ${apiRoutes.length} API route(s):`);
542
+ for (const r of apiRoutes) {
543
+ console.log(` ${r.methods.join("|").padEnd(20)} ${r.route}`);
544
+ }
545
+ console.log(` ✅ Written: node-ssr/generated-api-routes.ts`);
546
+ } else {
547
+ console.log(" No API routes found (app/api/ is empty or missing)");
548
+ }
549
+
550
+ // Write SSG manifest for the build step
551
+ const ssgRoutes = routes.filter(r => r.isStatic);
552
+ if (ssgRoutes.length > 0) {
553
+ const ssgManifest = {
554
+ pages: ssgRoutes.map(r => ({
555
+ route: r.route,
556
+ componentId: r.componentId,
557
+ hasGetServerProps: r.hasGetServerProps,
558
+ }))
559
+ };
560
+ fs.mkdirSync("dist", { recursive: true });
561
+ fs.writeFileSync("dist/ssg-manifest.json", JSON.stringify(ssgManifest, null, 2));
562
+ console.log(` 📄 SSG: ${ssgRoutes.length} page(s) will be pre-rendered at build time`);
563
+ }
564
+
257
565
  console.log(" 🎉 Routes generated successfully!");
258
566
  }
259
567
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blumenjs",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "The React framework powered by Go. Lightning-fast SSR with the DX you deserve.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -49,9 +49,11 @@
49
49
  },
50
50
  "homepage": "",
51
51
  "dependencies": {
52
- "lucide-react": "^1.14.0",
53
- "react": "^18.2.0",
54
- "react-dom": "^18.2.0"
52
+ "lucide-react": "^1.14.0"
53
+ },
54
+ "peerDependencies": {
55
+ "react": "^18.2.0 || ^19.0.0",
56
+ "react-dom": "^18.2.0 || ^19.0.0"
55
57
  },
56
58
  "devDependencies": {
57
59
  "@babel/core": "^7.29.0",